Skip to content

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:

{
  "mcpServers": {
    "lazytools": { "command": "lazytools-mcp" }
  }
}

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":

{"task": "is the new retry logic correct?", "repo_path": "LazyTools", "diff_ref": "main"}

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:

{"question": "and does that also affect the other caller?", "thread_id": "01a0...c509d3"}

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/Grep scoped to the reviewed repository and nothing else, so git_diff / git_status are supplied as ordinary LazyBridge tools instead;
  • no native harness — the Agent SDK has no review/start, so there is no claude_review_changes counterpart.

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:

  1. Provider-level configuration (authoritative). default_providers() constructs every provider in its read-only shape — DataHubTools() without allow_raw_series / allow_refresh, RegimeTools() with allow_write=False, which never even emit their write tools.
  2. A name-based guard (secondary). read_only=True additionally drops any tool whose name matches UNSAFE_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-unsafe constructs 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=False to build_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.

PROVIDER_FACTORIES module-attribute

PROVIDER_FACTORIES: dict[str, Callable[..., Any]] = {}

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 MCP Tool per expanded LazyBridge tool, with inputSchema taken verbatim from tool.definition().parameters;
  • call_toolawait 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
def 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 MCP ``Tool`` per expanded LazyBridge tool, with
      ``inputSchema`` taken verbatim from ``tool.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"``).
    """
    try:
        import mcp.types as types
        from mcp.server.lowlevel import Server
    except ImportError as exc:  # pragma: no cover - exercised without the extra
        raise ImportError(
            'lazytools.mcp_server requires the MCP SDK: pip install "lazytoolkit[mcp] @ git+https://github.com/selvaz/LazyTools.git"'
        ) from exc

    tool_map = expand_tools(providers, read_only=read_only, unsafe_patterns=unsafe_patterns)
    logger.info("LazyTools MCP server exposing %d tool(s): %s", len(tool_map), ", ".join(sorted(tool_map)))

    server: Server = Server(name, version=version, instructions=instructions)

    @server.list_tools()
    async def _list_tools() -> list[types.Tool]:
        listed: list[types.Tool] = []
        for tool in tool_map.values():
            definition = tool.definition()
            listed.append(
                types.Tool(
                    name=definition.name,
                    description=definition.description or "",
                    inputSchema=definition.parameters,
                )
            )
        return listed

    @server.call_tool()
    async def _call_tool(tool_name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
        tool = tool_map.get(tool_name)
        if tool is None:
            return [types.TextContent(type="text", text=f"Unknown tool: {tool_name!r}")]
        result = await tool.run(**(arguments or {}))
        return [types.TextContent(type="text", text=result_to_text(result))]

    return server

serve_stdio async

serve_stdio(server: Server) -> None

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
async def serve_stdio(server: Server) -> None:
    """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.
    """
    from mcp.server.stdio import stdio_server

    init_options = server.create_initialization_options()
    async with stdio_server() as (read_stream, write_stream):
        await server.run(read_stream, write_stream, init_options)

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
def 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.
    """
    selected = list(PROVIDER_FACTORIES) if ids is None else ids
    unknown = [i for i in selected if i not in PROVIDER_FACTORIES]
    if unknown:
        raise ValueError(
            f"Unknown provider id(s): {', '.join(unknown)}. Known: {', '.join(PROVIDER_FACTORIES)}"
        )

    providers: list[Any] = []
    for provider_id in selected:
        try:
            built = PROVIDER_FACTORIES[provider_id](allow_write, data_source=data_source)
        except Exception as exc:  # construction failure is non-fatal
            import logging

            logging.getLogger("lazytools.mcp_server").warning(
                "Could not construct provider %r: %s", provider_id, exc
            )
            continue
        if isinstance(built, (list, tuple)):
            providers.extend(built)
        else:
            providers.append(built)
    return providers

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
def 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")``.
    """
    patterns = tuple(unsafe_patterns)
    result: dict[str, Tool] = {}
    for provider in providers:
        label = getattr(provider, "name", None) or type(provider).__name__
        try:
            if getattr(provider, "_is_lazy_tool_provider", False):
                tools = list(provider.as_tools())
            elif isinstance(provider, Tool):
                tools = [provider]
            elif getattr(provider, "_is_lazy_agent", False):
                tools = [Tool.wrap(provider)]  # agents carry their own name
            elif callable(provider):
                # Plain function: the Tool constructor defaults the name to
                # ``func.__name__``. (``Tool.wrap`` *requires* an explicit
                # name for callables and would raise here, silently dropping
                # the function via the except below.)
                tools = [Tool(provider)]
            else:
                raise TypeError(f"cannot expose {type(provider).__name__!r} as a tool")
        except Exception as exc:
            logger.warning("Skipping tool provider %s: %s", label, exc)
            continue

        for tool in tools:
            if read_only and _is_unsafe(tool.name, patterns):
                logger.warning(
                    "read_only: dropping mutating tool %r (matched unsafe pattern). "
                    "Pass read_only=False to expose it.",
                    tool.name,
                )
                continue
            if tool.name in result:
                logger.warning("Tool name collision on %r — keeping the later registration.", tool.name)
            result[tool.name] = tool
    return result

result_to_text

result_to_text(result: Any) -> str

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.

Source code in src/lazytools/mcp_server/server.py
def result_to_text(result: Any) -> str:
    """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.
    """
    if result is None:
        return "null"
    if isinstance(result, str):
        return result
    if isinstance(result, (bytes, bytearray)):
        try:
            return bytes(result).decode("utf-8")
        except Exception:
            return repr(bytes(result))

    dump_json = getattr(result, "model_dump_json", None)
    if callable(dump_json):
        try:
            return dump_json()
        except Exception:
            pass

    to_json = getattr(result, "to_json", None)
    if callable(to_json):
        try:
            value = to_json()
            return value if isinstance(value, str) else json.dumps(value, default=_json_default, ensure_ascii=False)
        except Exception:
            pass

    try:
        return json.dumps(result, default=_json_default, ensure_ascii=False, indent=2)
    except Exception:
        return str(result)