MCP server¶
The mirror of the MCP connector. Where the connector turns an
external MCP server into lazybridge.Tool entries, the MCP server
does the opposite: it exposes LazyTools' own tool providers over MCP so
any MCP host — Claude Desktop, Claude Code, ChatGPT Codex — can call
datahub_*, statistical_*, regime_* and web search/crawl as native
tools.
Status: alpha. Install:
pip install "lazytoolkit[mcp] @ git+https://github.com/selvaz/LazyTools.git".
The bridge is thin because LazyBridge already normalises every tool behind
one abstraction: tool.definition() yields the JSON Schema MCP wants for
inputSchema, and await tool.run(**kwargs) dispatches with argument
validation built in. The whole server is expand providers → list them →
dispatch calls.
Quick start¶
Run it over stdio (the transport MCP hosts launch):
lazytools-mcp # all read-only providers
lazytools-mcp datahub statistical # a subset (positional provider ids)
python -m lazytools.mcp_server # equivalent module form
Point Claude Desktop / Claude Code at it:
That's it — the host now sees LazyTools' read-only surface as native tools.
Providers whose optional extra is missing are simply skipped, so a bare
[mcp] install serves datahub + statistical; install
lazystats[regimes] / the [web] extra to light up regimes / web.
Providers¶
lazytools-mcp serves the read-only provider menu. Pass ids to select a
subset, or set LAZYTOOLS_MCP_PROVIDERS=datahub,statistical in the env.
| id | Provider | Tools | Needs |
|---|---|---|---|
registry |
RegistryTools() |
registry_status, artifact_search, artifact_get (+ artifact_register with --allow-unsafe) |
none (core) |
datahub |
DataHubTools() |
datahub_* discovery, resolution, financial facts |
market-data-hub installed |
statistical |
StatisticalAnalysisTools() |
volatility, correlation, outliers, regression | market-data-hub installed |
regimes |
RegimeTools() |
regime_* read-only inspection |
lazystats[regimes] |
web |
WebTools() |
search / crawl / get-page | [web] extra |
fin |
PortfolioOptimizationTools() + PortfolioTreeTools() |
portfolio_optimizer_* (flat node) + portfolio_tree_* (multi-node, interoperable with LazyPortfolio's Tree Studio) |
lazyfin, lazyportfolio |
optimizer_agent |
optimizer_specialist(...) — a lazybridge.Agent |
one tool, portfolio-optimizer-specialist(task: str) |
--allow-unsafe + DEEPSEEK_API_KEY (opt-in only, see below) |
report_agent |
report_specialist(...) — a lazybridge.Agent |
one tool, report-specialist(task: str) |
--allow-unsafe + DEEPSEEK_API_KEY (opt-in only, see below) |
code_review |
codex_reviewer(...), codex_consultant(...), codex_native_reviewer(...) — lazybridge.Agents on CodexEngine |
codex_code_review, codex_ask, codex_review_changes |
--allow-unsafe + a locally authenticated codex CLI (opt-in only, see below) |
claude_review |
claude_reviewer(...), claude_consultant(...) — lazybridge.Agents on ClaudeCodeEngine |
claude_code_review, claude_ask |
--allow-unsafe + a locally authenticated claude CLI (opt-in only) |
optimizer_agent/report_agent are the two providers that construct a real
lazybridge.Agent instead of a deterministic ToolProvider — calling their
tool runs a live LLM-driven loop (its own internal tool calls) and returns
only the final text. Unlike everything else on this menu, they are never
served in the default read-only surface, and won't construct even with
--allow-unsafe unless the configured model's API key is set (default model
deepseek-v4-flash, override with LAZYTOOLS_OPTIMIZER_AGENT_MODEL /
LAZYTOOLS_REPORT_AGENT_MODEL).
code_review — hand a review to Codex¶
code_review is the same idea pointed at your own repositories:
lazybridge.Agents whose engine is
CodexEngine
— JSON-RPC to the locally authenticated codex app-server, so there is no API
key, only the CLI's own login — served as two tools:
codex_code_review(task, repo_path=None, diff_ref=None, paths=None, thread_id=None) -> str
codex_ask(question, repo_path=None, thread_id=None, model=None, effort=None) -> str
codex_review_changes(repo_path=None, scope="uncommitted", ref=None) -> str
codex_code_review finds defects in code you point it at; codex_ask answers a
design question about it ("does this protocol support X", "what breaks if I
change Y"); codex_review_changes runs Codex' own review harness over a typed
target (uncommitted / branch + ref / commit + ref) with no instructions
from us at all. The split is not cosmetic: the reviewer's instructions turn
every question into a findings list, and the native harness cannot be steered
because the protocol has no prompt slot for it.
The consultant differs from the reviewers in two more ways. codex_ask takes
per-call model / effort overrides ("same question, stronger model" is a
legitimate consulting move; the env-var settings below remain the defaults).
And it carries the server's own read-only LazyTools toolset as Codex
dynamic tools — the web, datahub, statistical, fin and
econ_calendar providers, built from the same factories and configuration
the MCP server serves, so a consultant that searches the web or runs the
optimizer hits the same databases and caches as the equivalent MCP tools.
(Dynamic tools ride the app-server channel and need no MCP registration on
the participant's side — a Codex thread reaching this server through its own
config.toml MCP entry gets rejected by approval_policy="never" before
execution, which is exactly the path this avoids.) The reviewers get the
web too (since 2026-08-19 — a review can need to check a CVE or whether an
API is really deprecated), but not the LazyTools toolset: a reviewer reads
code, it does not run the optimizer.
Codex reads the files and runs git itself, so a call is "point it at a
repository and say what to look at":
Follow-ups are cheap. Every reply's header carries thread_id=<id>; pass it
back and the next call continues the same Codex thread, which still holds what
it read and concluded, instead of re-exploring the repository from scratch:
Threads are durable (they live in the Codex CLI's own session store), so this works across calls and across processes — but a thread belongs to the repository it was opened on; don't reuse one against a different repo.
It runs in Codex' read-only sandbox (approval_policy="never", so nothing
can block the non-interactive transport): it reports, it never patches. Like
the other agent providers it is opt-in (--allow-unsafe) because a call spends
a real model turn; it is skipped entirely when the codex CLI can't be found.
| Setting | Default | Meaning |
|---|---|---|
code_root (--config) / LAZYTOOLS_CODE_ROOT |
server cwd | Directory every repo_path is confined to (and paths to repo_path in turn) — a caller cannot walk the reviewer out of it through an argument. The free-text task can still ask; the reviewer prompt refuses, but that is an instruction, not a sandbox, so keep secrets out of the root. |
LAZYTOOLS_CODE_REVIEW_MODEL |
the local ~/.codex/config.toml model |
Model override. |
LAZYTOOLS_CODE_REVIEW_EFFORT |
the CLI's default | low / medium / high. |
LAZYTOOLS_CODE_REVIEW_TIMEOUT |
900 |
Seconds per review. |
A review takes minutes, so give the host a matching tool timeout (Claude
Code: MCP_TOOL_TIMEOUT in ms) — otherwise the host cancels the call before
Codex answers.
claude_review — the same thing on the other model family¶
claude_code_review(task, repo_path=None, diff_ref=None, paths=None, session_id=None) -> str
claude_ask(question, repo_path=None, session_id=None, model=None, thinking=None) -> str
claude_ask mirrors codex_ask's consulting extras too: per-call model /
thinking overrides, the same read-only LazyTools toolset (served to the
engine as its in-process MCP server), and the engine's own WebSearch/WebFetch
(web=True). claude_code_review gets that same web access — build it with
claude_reviewer(web=False) for the old offline behavior — but not the
LazyTools toolset.
Identical arguments and the identical durable-handle protocol (session_id=
instead of thread_id=), on ClaudeCodeEngine — so the same diff can go to
both and the answers can be compared. That is the point of having two: a
reviewer that shares none of your assumptions is worth more than a second
opinion from the same family.
Two differences come from the runtime, not from choice:
- no shell — the engine grants
Read/Glob/Grepscoped to the reviewed repository and nothing else, sogit_diff/git_statusare supplied as ordinary LazyBridge tools instead; - no native harness — the Agent SDK has no
review/start, so there is noclaude_review_changescounterpart.
Model via LAZYTOOLS_CLAUDE_REVIEW_MODEL (default sonnet), extended thinking
via LAZYTOOLS_CLAUDE_REVIEW_THINKING; the confinement root and per-call
timeout are the same LAZYTOOLS_CODE_ROOT / LAZYTOOLS_CODE_REVIEW_TIMEOUT as
above. Registered as its own provider so a missing codex CLI cannot take the
Claude tools down with it, and vice versa.
Safety model¶
The server is read-only by default, enforced in two layers:
- Provider-level configuration (authoritative).
default_providers()constructs every provider in its read-only shape —DataHubTools()withoutallow_raw_series/allow_refresh,RegimeTools()withallow_write=False, which never even emit their write tools. - A name-based guard (secondary).
read_only=Trueadditionally drops any tool whose name matchesUNSAFE_TOOL_PATTERNS(e.g.*_send,*_write,*_delete,*_ensure_*,*_fit) with a logged warning — a coarse net for the case where a write-enabled provider is passed in by mistake.
There is no interactive ConfirmationGate over MCP, so mutating tools stay
off the default surface. To expose them you must opt in explicitly:
- CLI —
--allow-unsafeconstructs the providers in write-enabled mode (default_providers(ids, allow_write=True)) and disables the name guard, so the menu's writers (datahub refresh/register, regime fit/persist/delete) are emitted and served. No gating is applied. - Programmatic — build write-enabled providers yourself and pass
read_only=Falsetobuild_server, ideally with your own allow-list / confirmation wrapper around the mutating tools.
Programmatic use¶
import asyncio
from lazytools.mcp_server import build_server, serve_stdio, default_providers
# Read-only by default.
server = build_server(default_providers())
asyncio.run(serve_stdio(server))
build_server accepts any mix of ToolProviders, Tools, and plain
callables — so you can serve a custom, curated surface:
from lazytools.connectors.datahub import DataHubTools
from lazytools.statistical_analysis import StatisticalAnalysisTools
server = build_server(
[DataHubTools(), StatisticalAnalysisTools()],
name="lazytools-finance",
instructions="Read-only market data + statistics.",
)
API¶
lazytools.mcp_server ¶
LazyTools MCP server — expose LazyTools' tool providers over MCP.
The mirror of :mod:lazytools.connectors.mcp (the MCP client). Where the
client turns an external MCP server into lazybridge.Tool entries, this
server turns LazyTools' own providers into an MCP endpoint any host
(Claude Desktop, Claude Code, Codex, …) can call.
Quick start (stdio, the transport MCP hosts launch)::
python -m lazytools.mcp_server # all read-only providers
python -m lazytools.mcp_server datahub statistical # a subset
Claude Desktop / Claude Code config::
{
"mcpServers": {
"lazytools": { "command": "lazytools-mcp" }
}
}
Programmatic use::
import asyncio
from lazytools.mcp_server import build_server, serve_stdio, default_providers
server = build_server(default_providers()) # read-only by default
asyncio.run(serve_stdio(server))
Install with::
pip install "lazytoolkit[mcp] @ git+https://github.com/selvaz/LazyTools.git"
The server is read-only by default; see :func:build_server for the
safety model.
UNSAFE_TOOL_PATTERNS
module-attribute
¶
UNSAFE_TOOL_PATTERNS: tuple[str, ...] = ('_send', '_write', '_delete', '_register', '_ensure_', '_refresh', '_persist', '_save', 'save_', '_export_', '_fit', '_init_db', 'optimizer_run', 'optimizer_backtest', 'optimizer_create', 'tree_estimate', 'tree_backtest', '_create_draft', '-specialist', 'codex_', 'claude_code_review', 'claude_ask')
build_server ¶
build_server(providers: Sequence[Any], *, name: str = 'lazytools', version: str | None = None, read_only: bool = True, unsafe_patterns: Iterable[str] = UNSAFE_TOOL_PATTERNS, instructions: str | None = None) -> Server
Build a low-level MCP :class:~mcp.server.lowlevel.Server for providers.
The returned server has two handlers wired:
list_tools→ one MCPToolper expanded LazyBridge tool, withinputSchemataken verbatim fromtool.definition().parameters;call_tool→await tool.run(**arguments), the return value serialized via :func:result_to_text. Tool errors are returned as MCP error results (isError=True) instead of crashing the session.
Requires the mcp extra (pip install "lazytoolkit[mcp] @ git+https://github.com/selvaz/LazyTools.git").
Source code in src/lazytools/mcp_server/server.py
serve_stdio
async
¶
Run server over stdio until the client disconnects.
This is the transport Claude Desktop / Claude Code launch: the host spawns the process and speaks JSON-RPC over its stdin/stdout.
Source code in src/lazytools/mcp_server/server.py
default_providers ¶
default_providers(ids: list[str] | None = None, *, allow_write: bool = False, data_source: dict[str, Any] | None = None) -> list[Any]
Instantiate the default providers.
ids selects a subset (validated against :data:PROVIDER_FACTORIES);
None builds them all. allow_write is threaded to every factory —
False (default) yields read-only providers, True yields the
write-enabled shapes (the CLI's --allow-unsafe path). data_source
is ALSO threaded to every factory (each already accepts it -- path
for market-data-hub, regime_db_path for the regime depot, etc.) --
until this was wired up here, every factory's data_source parameter
was silent dead code: nothing ever called default_providers with one,
so it was always None no matter what a factory's own docstring
implied was configurable. See __main__.py's --config flag for
where a caller actually populates this now.
A factory that raises at construction time (rare — most only fail later,
at as_tools()) is skipped so one missing dependency never sinks the
server. A factory may return a single provider, or a list/tuple
of several (e.g. report's [ReportTools(...), ReportFiles(...)] —
the same shape lazytools.skills uses to wire the same two providers
for an agent) — either shape is flattened into the returned list.
Source code in src/lazytools/mcp_server/providers.py
expand_tools ¶
expand_tools(providers: Sequence[Any], *, read_only: bool = True, unsafe_patterns: Iterable[str] = UNSAFE_TOOL_PATTERNS) -> dict[str, Tool]
Expand providers into a name -> Tool map, isolating failures.
Each item may be a :class:lazybridge.Tool, a plain callable, an Agent,
or a ToolProvider (anything with as_tools()). Providers are
expanded independently: if one raises on expansion — typically an
ImportError because its optional extra is not installed (e.g.
RegimeTools without lazystats[regimes]) — it is skipped with a
warning and the rest still load. This is what lets a bare
pip install "lazytoolkit[mcp] @ git+https://github.com/selvaz/LazyTools.git" serve datahub + statistical while
regimes/web light up only once their extras are present.
On a name collision the last registration wins (with a warning),
mirroring build_tool_map(collision_policy="replace").
Source code in src/lazytools/mcp_server/server.py
result_to_text ¶
Serialize a tool's return value to a single text payload for MCP.
Strings pass through untouched; bytes are decoded as UTF-8; everything
else is rendered as pretty JSON (pydantic model_dump_json /
to_json fast-paths first), never raising.