Skip to content

MCP server

Expose LazyTools' project and coding tools to Claude Code, Codex or another MCP host. Engine-backed write/review tools are synchronous; detached work and durable approval tickets belong to the async code bridge. Other connectors support data, reports and communication. The MCP connector is the reverse direction: it imports another server's tools into an agent.

LazyTools 0.10.4 is Alpha. Install the coding runtime dependencies too:

python -m pip install "lazytoolkit[engines,mcp] @ git+https://github.com/selvaz/LazyTools.git@v0.10.4"

Collaboration setup

From your workspace root, save lazytools.json with local paths:

{"code_root": ".", "projects_store_db": "projects.sqlite"}

Then launch the same provider combination linked from the homepage:

lazytools-mcp projects code_review claude_review code_write code_sessions --allow-unsafe --config lazytools.json

code_root confines repository/cwd arguments; Claude Bash is not path-confined. projects_store_db makes state durable; without config or the Store env vars, Projects uses memory. Core project tools are on by default. For contracts and verification tools, set LAZYTOOLS_PROJECTS_TOOLS=full in the server environment. Sign in to the local coding runtimes; with a subscription login their turns use plan quota. Authentication can also follow another runtime-configured billing path. Coding providers require explicit selection and --allow-unsafe.

A stdio host can launch the server using this configuration (the host's working directory must be the intended workspace, or replace the config with an absolute path). The executable must be available in that host's environment:

{
  "mcpServers": {
    "lazytools": {
      "command": "lazytools-mcp",
      "args": ["projects", "code_review", "claude_review", "code_write", "code_sessions",
               "--allow-unsafe", "--config", "lazytools.json"],
      "env": {"LAZYTOOLS_PROJECTS_TOOLS": "full"}
    }
  }
}

--allow-unsafe enables writes without an interactive confirmation gate. It is separate from the bridge's durable approvals. Select only the providers needed. MCP calls block for the delegated turn; configure host timeouts or use detached bridge jobs. See code support for Claude-writes/Codex-reviews calls and session_name aliases.

For a persistent server, --http --host 127.0.0.1 --port 8787 serves Streamable HTTP at /mcp; a compatible host uses a type: "http" entry and that URL. The default transport is stdio and logs go to stderr.

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, a bare [mcp] install serves available core providers such as registry/projects/report. datahub and statistical additionally need the separately installed hub; install lazystats[regimes] / the [web] extra to light up regimes / web.

Providers

lazytools-mcp serves the available read-only providers by default. Coding and specialist-agent providers require explicit selection and opt-in. 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)
projects ProjectsTools() records, owner, plans, schedule/Gantt, quota, notes, cost; full profile adds contracts/verification configured Store for persistence; writes need --allow-unsafe
code_write CodeWriteTools(...) codex_write, claude_code_write --allow-unsafe, local runtimes; Claude needs [engines]
code_sessions CodeSessionTools(...) list/bind/rename/forget native aliases explicit opt-in with --allow-unsafe
code_review codex_reviewer(...), codex_consultant(...), codex_native_reviewer(...) — Tools that delegate through 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(...) — Tools that delegate through ClaudeCodeEngine claude_code_review, claude_ask --allow-unsafe + a locally authenticated claude CLI (opt-in only)

web — keep interactive research out of the production news DB

By default the web provider uses LAZYCRAWLER_NEWS_DB for everything, read and write: this is what scheduled production agents rely on, and it is unchanged when the variable below is unset. An interactive session (Claude Code through the workspace .mcp.json, one-off research) should not share that store: its pages land in the same pages/sessions tables with nothing marking them as interactive, and its writes contend with the scheduled crawl's lock (database is locked). Set LAZYTOOLS_WEB_CACHE_DB to isolate it:

LAZYTOOLS_WEB_CACHE_DB Effect
unset Today's behaviour: the crawler reads and writes LAZYCRAWLER_NEWS_DB.
a file path web_search, web_crawl, web_crawl_many write to that SQLite file (created on demand, with its directory; safe to delete).
empty or default Same, at ~/.lazytools/web_cache.sqlite (LAZYTOOLS_DATA_DIR replaces ~/.lazytools).
none (or :memory:) Nothing on disk: pages live in process memory and vanish on exit.

When it is set, LAZYCRAWLER_NEWS_DB (if it names an existing file) is opened read-only (SQLite mode=ro: no writes, no WAL pragma, no migrations) and the read tools merge the two stores. There is no write-through and no copying:

Tool Reads
get_page the interactive row if present, else the production row
get_session_pages, get_crawl_graph the interactive store if it has rows for that session, else production
get_artifacts, get_rag_document the one store that owns the page (or session): interactive if it holds it, even with no artifacts, else production. A newer interactive copy of a URL never inherits the production copy's tables or images
search_cached interactive hits first, then production hits, de-duplicated by page, cut to limit
get_cache_stats {"interactive": {...}, "production": {...} or null} instead of flat counts

The fetch tools do not reuse production pages as a fetch cache (they only know the interactive store), so a URL already in news.db is fetched again.

Guard. If the cache path is the same file as LAZYCRAWLER_NEWS_DB (or its -wal/-shm/-journal side files), or lies anywhere under C:\ProgramData\InvestmentCommittee, the provider raises WebCachePathError and the server refuses to start: this error is not downgraded to a skipped provider. The guard only runs when LAZYTOOLS_WEB_CACHE_DB is set.

Enable it for interactive use by adding the variable to the workspace .mcp.json server env (never to the production project.toml env):

{
  "mcpServers": {
    "lazytools": {
      "command": "lazytools-mcp",
      "args": ["web"],
      "env": {
        "LAZYCRAWLER_NEWS_DB": "C:\\ProgramData\\InvestmentCommittee\\live\\db\\news.db",
        "LAZYTOOLS_WEB_CACHE_DB": "default"
      }
    }
  }
}

.mcp.json variables relevant to web: LAZYCRAWLER_NEWS_DB (production store, read-only once isolation is on), LAZYTOOLS_WEB_CACHE_DB (this section), LAZYTOOLS_WEB_MODEL (smart-mode extraction model, default deepseek-v4-flash), LAZYTOOLS_DATA_DIR (base of the default cache path). --config accepts web_cache_db (same values) and news_db_path for the same two settings.

optimizer_agent/report_agent are 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. Like coding providers, 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, inheriting its runtime authentication — served as three tools:

codex_code_review(task, repo_path=None, diff_ref=None, paths=None, thread_id=None, session_name=None, model=None, effort=None) -> str
codex_ask(question, repo_path=None, thread_id=None, session_name=None, model=None, effort=None) -> str
codex_review_changes(repo_path=None, scope="uncommitted", ref=None, session_name=None, model=None, effort=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.

Every Codex tool takes per-call model / effort overrides ("same question, stronger model" is a legitimate consulting move; the env-var settings below remain the defaults). effort is checked: one of none, minimal, low, medium, high, xhigh, max, ultra, else a ValueError lists them. The consultant differs from the reviewers in one more way: 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 reuse native history. 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.

Or name the conversation. Pass session_name="notes" on any of these tools and the thread is remembered under that alias (per repository, in LazyBridge's SessionRegistry file): the first call opens it, later calls with the same name resume it, and no id needs carrying. An explicit thread_id still wins and re-points the name at it. The header then reads thread_id=<id> session_name=notes.

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 Accepted vocabulary: none, minimal, low, medium, high, xhigh, max, ultra; actual support depends on model/runtime.
LAZYTOOLS_CODE_REVIEW_TIMEOUT 3600 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, session_name=None, model=None, effort=None) -> str
claude_ask(question, repo_path=None, session_id=None, session_name=None, model=None, thinking=None, effort=None) -> str

claude_ask mirrors codex_ask's consulting extras too: per-call model / thinking / effort overrides (effort: low, medium, high, xhigh, max), session_name, 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), reasoning effort via LAZYTOOLS_CLAUDE_REVIEW_EFFORT, 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.

code_write — let Codex / Claude Code edit

codex_write(task, cwd=None, thread_id=None, session_name=None, model=None, effort=None) -> dict | str
claude_code_write(task, cwd=None, session_id=None, session_name=None, model=None, effort=None) -> dict | str

Opt-in (--allow-unsafe), confined to the code root, and now run on the LazyBridge engines like the review tools, with the same per-call model, effort and session_name. codex_write no longer takes resume_last — continue a conversation by session_name or the previous reply's thread_id. Claude's writer edits only inside its cwd and runs Bash behind an allow-list gate; Bash is not path-confined, so treat the code root as fully trusted scope.

code_sessions — see and manage session names

code_sessions_list(kind=None, repo_path=None, limit=50, since_days=None) -> dict
code_sessions_bind(kind, name, native_id, repo_path=None) -> dict
code_sessions_rename(kind, old, new, repo_path=None) -> dict
code_sessions_forget(kind, name, repo_path=None) -> dict

session_name aliases live in LazyBridge's SessionRegistry (a JSON file, ~/.lazybridge/sessions.json or LAZYBRIDGE_SESSIONS_FILE), keyed by kind (codex or claude), the resolved repository and the name. code_sessions_list is read-only and returns sessions, total (matching rows before the limit) and returned (included count). Each session has name, kind, native_id, scope, model, effort and updated_at. Sessions are sorted by updated_at descending. The default limit is 50, capped at 200 (minimum 1). Optional non-negative since_days keeps sessions updated within that many days; all filters apply before counting and limiting. The three mutators adopt a thread you already have (bind), rename, or drop a name (forget never deletes the underlying conversation); they are only served with --allow-unsafe, and repo_path is confined to the code root like every other path.

Safety model

Structured tool results use strict JSON: non-finite floats (NaN, positive or negative infinity, including NumPy floats) and pandas NA become null recursively. This also applies to model/dataclass conversions and JSON serializer output. Plain string and byte results retain their text behavior.

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.mcp_server import build_server
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', 'code_sessions_bind', 'code_sessions_rename', 'code_sessions_forget')

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_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
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 WebCachePathError:
            raise  # a cache path aliasing the production store must stop startup, not be skipped
        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.

Plain strings pass through untouched; a string that is a JSON object or array (many providers return pre-serialized JSON) is re-emitted as strict JSON; bytes are decoded as UTF-8; everything else is rendered as strict JSON. Non-finite floats and pandas NA become null, including inside model/dataclass conversions and JSON serializers. Serialization errors propagate to the MCP error handler.

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.

    Plain strings pass through untouched; a string that is a JSON object or
    array (many providers return pre-serialized JSON) is re-emitted as strict
    JSON; bytes are decoded as UTF-8; everything else is rendered as strict
    JSON. Non-finite floats and pandas NA become null, including inside
    model/dataclass conversions and JSON serializers. Serialization errors
    propagate to the MCP error handler.
    """
    if result is None:
        return "null"
    if isinstance(result, str):
        return _strict_json_text(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:
            result = json.loads(dump_json())
        except Exception:
            pass

    to_json = getattr(result, "to_json", None)
    if callable(to_json):
        try:
            value = to_json()
            result = json.loads(value) if isinstance(value, str) else value
        except Exception:
            pass

    return json.dumps(_json_safe(result), default=_json_default, ensure_ascii=False, indent=2, allow_nan=False)