MCP 2026-07-28
Why this area is shaped the way it is, and what breaks under the alternative.
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:
- 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 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/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, 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→-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 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:
-
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. 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.