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:
- Changelog: https://modelcontextprotocol.io/specification/2026-07-28/changelog
- Authorization: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
- Authorization server discovery: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery
- Client registration: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration
- Streamable HTTP: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http
- MRTR pattern: https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
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/protocolVersionio.modelcontextprotocol/clientCapabilitiesio.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→-32020MissingRequiredClientCapability:-32003→-32021UnsupportedProtocolVersion:-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:
-
Request without a token. Server answers
401with aWWW-Authenticate: Bearerheader carryingresource_metadataand SHOULD carryscope. -
Fetch the Protected Resource Metadata (RFC 9728) from that URL. It names the authorization server(s).
-
Fetch authorization server metadata. Clients MUST support both RFC 8414 and OpenID Connect Discovery 1.0, tried in priority order.
-
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_typeto avoid OpenID Connect redirect URI conflicts.
- Client ID Metadata Documents (an HTTPS URL used directly as
-
Record the
issuerfrom the validated AS metadata, alongside the PKCE code verifier andstate. -
Authorization request with PKCE
code_challengeand theresourceparameter. -
On the callback, validate
issper RFC 9207 §2.4 before sending the code anywhere. The table:authorization_response_iss_parameter_supportedisspresentaction true yes compare to recorded issuer, simple string comparison true no reject false or absent yes compare to recorded issuer false or absent no proceed 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. -
Token request with
code_verifierandresource. -
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_credentialsclient 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 Exceptionand 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 asSseEventTooLarge— aBaseExceptionspecifically so that handler cannot swallow it — and translated at Psych's boundary intoMcpResponseTooLarge. - It is not an unreachable server. The server answered, correctly and in
full. Classifying this as unreachable would let
optional=Truedrop 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.