Psych Runtime
Guides

Connect MCP servers

An McpServer in a Spec is data. The tools it contributes are discovered from the server, never declared in the Spec.

An McpServer in a Spec is data. The tools it contributes are discovered from the server, never declared in the Spec.

psych_runtime.McpServer(
    name="github",
    url="https://mcp.example.com/github",
    transport="http",  # Streamable HTTP. See "Transports" below.
    credential="github-token",  # a NAME the SecretResolver resolves
    allow=("search_issues", "list_*"),
    optional=False,
    preload=None,
)

Wiring

from psych_runtime.tools.mcp import McpPool, McpTools
from psych_runtime.model.egress import HttpTransport

pool = McpPool(transport=HttpTransport(), secrets=secrets)  # ONE per process
runtime = psych_runtime.Runtime(store=store, model=model, registry=registry, mcp=McpTools(pool))

Without mcp=, a Spec can declare mcp_servers, publish, run, and the model is never offered a single one of those tools. No error and no warning: they are simply absent. That is the silent-incapacity failure the mcp= field exists to prevent, so wire it whenever a Spec might name a server.

McpTools carries both halves at once: the catalogue the resolver reads and the caller the executor uses. They must narrow identically, and wiring them separately is how they drift apart. A drift in that direction is a model calling a tool the Spec excluded.

The rule that matters more than the rest

Never pool by URL. Pool by (scope, server, credential). McpPool keys by a frozen McpPoolKey carrying the Scope's tenant and principal, the server URL and transport, and the resolved credential's identity, never the credential name from the Spec and never its value.

One pool per process is the intended shape. Every Run for every tenant shares it, and isolation comes entirely from the key. Building a pool per tenant just moves the same one-line mistake to whoever wires up the per-tenant pools.

Because a cached catalogue lives inside one pool key's connection, a server's cacheScope: "private" is satisfied by construction: there is no second cache for it to leak into.

Access narrows through three planes

What the server offers contains what the tenant permits contains what the Spec grants contains what is callable now. One function computes that intersection, and both the validator and the runtime call it so the two cannot drift.

allow is the Spec's plane. Empty means every tool the server offers, still subject to the tenant's plane. Patterns support a trailing * (allow=("list_*",)). Grant order carries no meaning, so allow is stored as a sorted set and does not change the Version hash.

The executor narrows again at call time. The resolver already filtered what the model was shown, and that is not a control, because the model chooses the name it sends. A name that does not survive narrowing is refused however the model got hold of it.

Every remote tool is exposed to the model as server__tool, for example github__search_issues. The qualified name preserves the owning connection through execution and replay, and lets two servers safely publish the same raw tool name. Deferred call_tool accepts a server plus its raw tool name and performs the same qualification internally. If any local, remote, built-in, or peer tool still resolves to a duplicate public name, resolution fails instead of silently choosing one.

Resolution is per turn, never at boot

resolve_mcp_server re-narrows against the live catalogue on every turn, so a Spec's grant is re-evaluated each time rather than pinned at connect. This is what makes a server connected mid-Run usable on the next turn without a restart, and it is one of the ten things Psych's e2e suite asserts.

The catalogue itself refreshes on connect, on a TTL sweep, on demand, and on notifications/tools/list_changed.

The official client follows every nextCursor from tools/list; a paginated server therefore contributes its complete catalogue. Modern subscriptions/listen and the older tools-list-changed notification both invalidate the same scoped cache. Structured tool results are retained and rendered alongside text instead of being discarded.

Large servers: deferred disclosure

A server with a big catalogue does not put its schemas in the prompt. It contributes three discovery tools instead (list_tools, get_tool_info, call_tool), and the model reads one tool's schema when it decides to use it.

Measured against a real 351-tool server whose schemas are 2.0 MB: 1,911 request bytes per turn instead of 2,082,521. Preloaded, that 2 MB is sent on every turn of every Run, past several providers' request limits outright and billed whenever it is not.

preload decides:

ValueBehaviour
None (default)Decide from the catalogue's size against the Runtime's catalogue_budget_chars (20,000). Honest, because the size is a fact about the server discovered at run time.
FalseAlways defer.
TrueAlways preload, when you would rather pay for schemas than have the model spend a turn discovering.

The budget is a Runtime field, not a Spec field, because it describes this deployment's prompt budget rather than the agent. Two Workers with different budgets can run the same Version. It is counted in characters, deliberately never scaled to resemble tokens: a token count needs a tokenizer, tokenizers differ per provider, and the same number would then mean different things depending on which model an agent named.

Discovery narrows exactly as an ordinary call does. It is a door, not a bypass.

Approvals go through the door too. A call_tool invocation is gated on the target tool's own name and annotations, exactly as if its catalogue had been preloaded: a @destructive selector that suspends github__delete_repo when preloaded suspends the same call made through call_tool, and a Policy is asked about github__delete_repo, never about call_tool. A target the Run cannot resolve is graded destructive, so the wrapper fails closed.

Unreachable servers

By default an unreachable server fails the Run. Set optional=True to omit its tools and tell the model they are unavailable instead.

Defaulting optional to True would produce an agent that confidently tells a customer it cannot issue refunds today. Being wrong loudly beats being wrong quietly, so the default is the strict one.

McpServerUnreachable.reason is always a sentence worth showing: a transport error with no text of its own (an asyncio.TimeoutError, a bare OSError) reads "the connection timed out" or names the exception type, and the message carries no trailing colon when there is nothing to put after it. A console can put str(error) in the row of the connection that failed as it is.

A catalogue too large to accept

A Streamable HTTP server writes each response as one server-sent event, and an event has a size ceiling, so the ceiling applies to tools/list — which for a server generated from an OpenAPI description can run to several megabytes.

Exceeding it raises McpResponseTooLarge, not McpServerUnreachable: the server answered correctly and in full, and this client declined to buffer the reply. The distinction is the point. Were it unreachable, optional=True would drop a healthy server and the agent would run without its tools with nobody told why.

Raise the ceiling with psych_runtime.set_max_sse_event_bytes. It is process-wide, because it belongs to the HTTP client every connection shares. Leave it bounded: an MCP server is a third party, and a reply nothing bounds can exhaust the process reading it.

Transports

"http" is Streamable HTTP (MCP revision 2026-07-28) and is the default. The client negotiates down to 2025-11-25 and 2025-06-18 servers through one connection, deciding once at connect rather than branching per request.

"sse" uses the older HTTP+SSE transport through the official MCP client. MCP has deprecated it, so use "http" for new connections unless the server only supports the older transport.

"stdio" is not an option. It was removed rather than left as an enum member Psych never implemented: a Spec could ask for it, validation passed, and the connection could never work. Adding a real stdio transport is open work, not a silent revival.

MCP tools inside run_code

An agent with code_execution can call every MCP tool it is currently granted from inside a program, through the same pool, the same connection, the same OAuth and the same three narrowing planes. There is no second MCP client, no MCP credential inside the sandbox, and no separate configuration surface: a program reaches the agent's existing connections or nothing.

rows = []
for sku in ("A", "B", "C"):
    rows.append(await support__lookup(sku=sku))  # identifier names
raw = await call_tool("docs__list-repos", {"owner": "psych"})  # any name

Three things follow from "the same narrowing", and each is worth knowing:

  • A deferred server's tools are bindable. They are absent from the prompt and present in the program, which is the case code execution is best at: a program pays nothing for a schema it never reads. list_tools and get_tool_info are callable from a program too, for the argument shapes.
  • Server-qualified names stay qualified. Two servers both offering search remain independently callable as docs__search and tickets__search, and each routes to its own scoped connection.
  • The set is re-resolved every turn. A tool the server removed, or a tenant policy withdrew, stops being callable from a program in the same turn it stops being callable directly. A Spec that named it explicitly gets it back as unavailable_bindings in the run_code result, never as a substitute.

A binding call is recorded as its own tool_call_started/tool_call_finished pair carrying parent_call_id, so a report shows forty MCP calls nested under the one run_code that made them.

MCP failures keep their identity inside the program: ToolError.kind is McpToolError when the tool ran and said no, and McpServerUnreachable when it never ran. A server asking for elicitation arrives as input_required_in_program, because nothing can answer it while a subprocess waits — the model is told to make that call itself.

Gotchas

  • credential is a name, never a value. Your SecretResolver resolves it fresh for the calling Scope on every use.
  • A server behind OAuth needs an OAuthClient on the pool. Without one, its first 401 is McpServerUnreachable with a message saying the server wants OAuth. See psych-mcp-oauth.
  • Structured tool results are preserved. Text content is returned as text; structuredContent is serialized when it carries additional information.
  • describe_server on McpTools is called at every turn boundary, so it must be a cheap in-memory lookup. It is deliberately not async, which is that signature's way of saying "no IO here".
  • Unannotated MCP tools are treated as write for approvals, and a partially annotated one reads destructiveHint's own default of true. See psych-approvals.
  • A static tool may not wear a server's prefix. A CodeTool named github__search_issues beside a server named github is refused at publish; otherwise the two collide at run time once the server exposes search_issues.
  • The bounded SSE event source is installed on every connection, not only at import, so an SDK module imported lazily cannot keep the SDK's 1 MB default.

On this page