Psych Runtime
Design notes

MCP 2026-07-28

Why this area is shaped the way it is, and what breaks under the alternative.

This note records the protocol changes that affect Psych and the implementation boundary that follows from them.

The specification is authoritative:

Where Psych actually is

psych_runtime/tools/mcp.py uses the official Python MCP SDK 2.x. The SDK owns protocol negotiation, request and response validation, Streamable HTTP, the older HTTP+SSE transport, pagination, subscriptions, and tool calls. It speaks the current 2026-07-28 revision and negotiates older handshake revisions.

Psych wraps the SDK where the runtime has information the protocol client does not. The wrapper pools by tenant, principal, server, transport, and resolved credential identity. It applies tenant and Spec tool grants, routes every URL through the consumer's egress policy, refreshes the tool catalogue, and exposes each remote tool as server__tool so two servers can use the same raw name.

The SDK also owns OAuth discovery, authorization code with PKCE, registered client credentials, token refresh, and 403 insufficient_scope step-up. Psych provides a Scope-isolated SDK token store, resolves client-secret names, checks every SDK request against the tenant's egress policy, and hands the SDK's authorization URL and callback through the host application's redirect port.

The compatibility OAuth path remains only for configurations the SDK does not yet represent, principally client credentials obtained through CIMD or dynamic registration. New client-credentials configurations use a registered confidential client and set an explicit authorization-server issuer.

McpServer.transport accepts "http" and "sse". It does not accept "stdio" because Psych does not own a subprocess lifecycle for MCP servers. Resources, prompts, sampling, roots, and elicitation are not exposed by the agent runtime. The current MCP integration is deliberately a tool client.

Breaking changes, and the backward-compatibility rule for each

The spec keeps a twelve-month deprecation window under its new feature lifecycle policy, so a client that has to talk to both old and new servers is the normal case, not an edge case.

Stateless core: no handshake, no session id

initialize and notifications/initialized are gone. Mcp-Session-Id is gone from Streamable HTTP. Every request now carries, in _meta:

  • io.modelcontextprotocol/protocolVersion
  • io.modelcontextprotocol/clientCapabilities
  • io.modelcontextprotocol/clientInfo (SHOULD)

Servers SHOULD put io.modelcontextprotocol/serverInfo in each result's _meta. A version mismatch returns UnsupportedProtocolVersionError.

Compatibility: an older server still expects the handshake. server/discover doubles as the probe: call it first, and fall back to the old handshake when the server does not implement it.

server/discover

Servers MUST implement it. It advertises supported protocol versions, capabilities and identity. Clients MAY call it before anything else for up-front version selection.

resultType on every result

Required: "complete" for an ordinary result, "input_required" for an MRTR interim result.

Compatibility, stated normatively: clients MUST treat a result from an earlier-protocol server that omits the field as "complete". This is the one piece of backward compatibility the spec spells out, and it is cheap.

Multi Round-Trip Requests replace server-initiated requests

roots/list, sampling/createMessage and elicitation/create are no longer sent by the server as requests. Instead a server returns an InputRequiredResult (resultType: "input_required") whose inputRequests field says what it needs. The client retries the original request with inputResponses.

Psych does not implement roots, sampling or elicitation. It raises a typed McpInputRequiredError when a server asks for one of them instead of treating the interim result as an empty success.

subscriptions/listen replaces the GET endpoint

The HTTP GET endpoint and resources/subscribe / resources/unsubscribe are replaced by a single long-lived POST-response stream. Clients opt into toolsListChanged, promptsListChanged, resourcesListChanged, resourceSubscriptions; notifications are tagged with io.modelcontextprotocol/subscriptionId.

This matters to Psych: the catalogue cache refreshes on notifications/tools/list_changed, which now arrives through this stream rather than a GET.

Removed: ping, logging/setLevel, notifications/roots/list_changed

Log level moves to io.modelcontextprotocol/logLevel in _meta, per request.

SSE resumability removed

Last-Event-ID and SSE event ids are gone from Streamable HTTP. A broken response stream loses the in-flight request and the client MUST re-issue it as a new request with a new id.

Note the distinction, because it is easy to get wrong: SSE framing still exists inside Streamable HTTP for streamed responses. What is deprecated is the old HTTP+SSE transport (a separate GET endpoint carrying a server-to-client SSE stream), deprecated since 2025-03-26 and now formally Deprecated under the lifecycle policy. New work targets Streamable HTTP.

New required headers

Mcp-Method and Mcp-Name on every Streamable HTTP POST. Custom headers may come from tool parameters via x-mcp-header.

CacheableResult

tools/list, prompts/list, resources/list, resources/read and resources/templates/list now require ttlMs and cacheScope ("public" or "private") on their results. ttlMs is a freshness hint.

Psych prefers the server's ttlMs over its own catalogue TTL. A catalogue lives inside one scoped connection, so a cacheScope: "private" result is never shared across pool keys.

Error code renumbering

  • HeaderMismatch: -32001 → -32020
  • MissingRequiredClientCapability: -32003 → -32021
  • UnsupportedProtocolVersion: -32004 → -32022
  • resource not found: -32002 → -32602 (Invalid Params)

-32000..-32019 stays implementation-defined; -32020..-32099 is reserved for the spec.

Deprecated features

Roots, Sampling and Logging are all deprecated. Psych implements none of them.

Authorization: what a compliant client has to do

Optional for HTTP transports, and SHOULD NOT be used for stdio (which retrieves credentials from the environment instead). But "optional" means Psych may choose not to protect its own server; a client talking to a protected server has no choice.

The flow, in order:

  1. Request without a token. Server answers 401 with a WWW-Authenticate: Bearer header carrying resource_metadata and SHOULD carry scope.

  2. Fetch the Protected Resource Metadata (RFC 9728) from that URL. It names the authorization server(s).

  3. Fetch authorization server metadata. Clients MUST support both RFC 8414 and OpenID Connect Discovery 1.0, tried in priority order.

  4. Get a client id, by one of three mechanisms in this priority:

    • Client ID Metadata Documents (an HTTPS URL used directly as client_id; the AS fetches the metadata from it). SHOULD be supported.
    • Pre-registered client id.
    • Dynamic Client Registration (RFC 7591), now deprecated and kept only for authorization servers that do not support CIMD. Requires an appropriate application_type to avoid OpenID Connect redirect URI conflicts.
  5. Record the issuer from the validated AS metadata, alongside the PKCE code verifier and state.

  6. Authorization request with PKCE code_challenge and the resource parameter.

  7. On the callback, validate iss per RFC 9207 §2.4 before sending the code anywhere. The table:

    authorization_response_iss_parameter_supportediss presentaction
    trueyescompare to recorded issuer, simple string comparison
    truenoreject
    false or absentyescompare to recorded issuer
    false or absentnoproceed

    No normalisation before comparing: no case folding, no default-port elision, no trailing-slash or percent-encoding normalisation. This applies to error responses too, and on mismatch the client MUST NOT even display error.

  8. Token request with code_verifier and resource.

  9. Use Authorization: Bearer <token> on every request. Never in a query string.

Grant types

  • Authorization code, always with PKCE. OAuth 2.1 makes PKCE mandatory; there is no "without PKCE" variant to support. A consumer asking for authorization code without PKCE is asking for OAuth 2.0, which this spec does not permit.
  • Client credentials, for a client acting on its own behalf rather than a user's. The spec references it explicitly in the step-up flow: a client_credentials client MAY attempt step-up or abort immediately.

Resource indicators, RFC 8707

resource MUST be sent on both the authorization request and the token request, MUST identify the MCP server, and MUST be its canonical URI. Clients MUST send it whether or not the AS supports it. Canonical URI has a scheme, no fragment, and conventionally no trailing slash.

Refresh

Clients wanting refresh tokens SHOULD include refresh_token in grant_types, MAY add offline_access to scope when the AS advertises it in scopes_supported, and MUST NOT assume a refresh token will be issued.

Credentials are bound to their issuer

Clients MUST key persisted credentials by the issuer identifier, MUST NOT reuse them with a different authorization server, and MUST re-register when the AS changes. Psych keys each SDK token store by Scope, resource, grant configuration, and credential identity. McpOAuth.issuer binds registered client credentials to the authorization server that may receive them.

Step-up on insufficient_scope

A 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="..." means re-authorize with the union of previously requested scopes and the challenged scopes, then retry. Bounded retries; treat repeated failure as permanent.

What this means for Psych's own invariants

The pool key carries (tenant, principal, server url, transport, credential identity). For OAuth, that identity describes the scoped grant and stays stable when the access token refreshes. Token bytes never enter the key.

DESIGN.md §10.4's rule is unchanged and gets harder: a token obtained for tenant A must never be sent on tenant B's call, and now there is a background refresh that could get that wrong asynchronously.

How large one response may be

A Streamable HTTP server answers a JSON-RPC request by writing the whole result as one server-sent event. The size of a response and the size of a single event are therefore the same number, and the HTTP client under the SDK caps one event at a megabyte by default. tools/list is the response that reaches it first: a server generated from an OpenAPI description can offer several hundred tools, each with a full JSON Schema, and two megabytes is ordinary.

Psych sets its own ceiling instead (tools/sse_events). Three things made that worth doing carefully rather than simply removing the limit:

  • It must stay bounded. An MCP server is a third party. A response with no ceiling is one that can exhaust the memory of the process reading it, which is the same class of hole as an unbounded stored result.
  • Reaching it must be legible. The SDK reads its stream inside a bare except Exception and reports every failure there as a stream that ended without a response. That text names neither the size nor the ceiling, and reads as a network fault, so the breach is reported as SseEventTooLarge — a BaseException specifically so that handler cannot swallow it — and translated at Psych's boundary into McpResponseTooLarge.
  • It is not an unreachable server. The server answered, correctly and in full. Classifying this as unreachable would let optional=True drop a healthy server and run the agent without its tools, silently.

The ceiling is process-wide rather than per-agent: it belongs to the HTTP client shared by every connection in the process, and the SDK's call path offers no point where a per-connection value could be threaded through.

Sources

On this page