Psych Runtime
Design notes

MCP 2026-07-28

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

This is the documentation for unreleased changes on main. For the released library, read v0.1.

Written 2026-09-02 from the published specification. This is a research brief, not a design: it records what the spec says and what the current code does, so the gap is explicit before anyone starts changing files.

Read the spec itself before implementing. This file is a map, and it will go stale. The authoritative pages:

Where Psych actually is

psych_runtime/tools/mcp.py pins _PROTOCOL_VERSION = "2025-06-18". That is two revisions behind: 2025-11-25 shipped in between and was also missed.

The client currently does an initialize / notifications/initialized handshake and carries an Mcp-Session-Id header. Both were removed in 2026-07-28. It has no server/discover, does not read resultType, and does not send the now-required Mcp-Method and Mcp-Name headers.

psych_runtime.core.spec.McpServer.transport accepts "stdio", and no stdio code exists. A Spec can ask for it and validation will pass and the connection will never work. That is a placeholder on a shipped path, which the repository forbids: either implement it or remove the enum member.

There is no OAuth of any kind. SecretResolver resolves a name to a static string and the client sends it as Authorization: Bearer <that string>. Every server requiring a real OAuth handshake fails today.

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, so this mostly means "recognise input_required and handle or refuse it deliberately" rather than "return nothing and behave as though the call succeeded".

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 already has a TTL on its catalogue cache. It should prefer the server's ttlMs over its own default when one is supplied, and must respect cacheScope: "private" by never sharing that entry 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 and should not start.

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. This lines up with Psych's existing pool key discipline and should use the same reasoning.

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 already carries (tenant, principal, server url, transport, credential identity). OAuth makes the credential identity a token rather than a static secret, and tokens expire and refresh. The identity must therefore be derived from the grant, not from the access token's bytes, or every refresh silently creates a new pool entry and leaks connections.

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.

Sources

On this page