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.
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.
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:
| Value | Behaviour |
|---|---|
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. |
False | Always defer. |
True | Always 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.
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.
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" is kept only so an existing Spec keeps resolving. It is Deprecated under
MCP's lifecycle policy; use "http" for anything new.
"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.
Gotchas
credentialis a name, never a value. YourSecretResolverresolves it fresh for the calling Scope on every use.- A server behind OAuth needs an
OAuthClienton the pool. Without one, its first 401 isMcpServerUnreachablewith a message saying the server wants OAuth. Seepsych-mcp-oauth. describe_serveronMcpToolsis called at every turn boundary, so it must be a cheap in-memory lookup. It is deliberately notasync, which is that signature's way of saying "no IO here".- Unannotated MCP tools are treated as
writefor approvals, and a partially annotated one readsdestructiveHint's own default of true. Seepsych-approvals.
Call an HTTP endpoint as a tool
An HttpTool is a URL, a method, a JSON schema and a credential name. Entirely data, which means an end user can create one at runtime through your console with no code and no...
Authenticate to an MCP server with OAuth 2.1
Psych ships a full OAuth 2.1 client that the MCP client drives automatically on a 401 or 403 from a protected server.