Serve the 2026-07-28 era over stdio and other stream-pair transports#3038
Conversation
A modern client over stdio previously had no path to the 2026-07-28
protocol: the stream loop only understood the initialize handshake, so a
server/discover probe was rejected and mode='auto' clients silently fell
back to the legacy era. The modern per-request-envelope path existed only
on the single-exchange HTTP entry.
Server.run now drives serve_dual_era_loop, the stream-pair counterpart of
the HTTP era router. The first era-distinctive inbound message locks the
connection (mid-stream era switching is undefined; this matches the
typescript-sdk):
- initialize locks legacy: the connection runs the existing loop runner
byte-identically for its lifetime, and modern envelope traffic is then
rejected with INVALID_REQUEST.
- A request carrying the modern _meta envelope triple, or server/discover
(a modern-only method), locks modern: each request is validated by
classify_inbound_request and served single-exchange via serve_one with a
born-ready per-request Connection, the same dispatch model as the modern
HTTP entry. A later initialize gets UNSUPPORTED_PROTOCOL_VERSION naming
the supported modern versions. A rejected classification never locks, so
a failed probe leaves the legacy handshake available.
Modern stream-pair connections push notifications over the duplex pipe
(NotifyOnlyOutbound) but refuse server-initiated requests on both the
standalone and request-scoped channels, as the 2026-07-28 protocol
requires. Client-side cancellation works unchanged: notifications/cancelled
is consumed at the dispatcher layer. subscriptions/listen is rejected with
METHOD_NOT_FOUND on this path for now - the registered handler assumes the
HTTP entry's stream semantics.
No client changes: the discover probe has always carried the envelope
triple in the request body, so existing auto-negotiating clients upgrade
to 2026-07-28 against this server, and their fallback predicate keys on
the error code alone (only -32022 is special-cased), so the new rejection
shapes cannot strand them.
The streamable-HTTP manager still calls serve_loop directly and is
unaffected. The run("stdio") tests now gate stdin EOF on the responses
having arrived, like a real client; closing immediately after the last
frame raced the dispatcher's EOF-time cancellation of in-flight handlers.
There was a problem hiding this comment.
I didn't find any bugs, but this PR introduces a new dual-era driver that changes protocol-era routing for every stream-pair transport (stdio, SSE, in-memory), so it deserves a human look at the design and interop guarantees.
Extended reasoning...
Overview
This PR adds serve_dual_era_loop in src/mcp/server/runner.py and switches Server.run to use it, so stdio/SSE/in-memory stream-pair connections can serve both the legacy handshake era and the modern 2026-07-28 per-request-envelope era. It also adds a notify-only outbound (NotifyOnlyOutbound), a request-refusing dispatch-context wrapper, and substantial new test coverage (driver-level, client end-to-end, and process-stdio tests, plus a flake fix in the existing stdio tests).
Security risks
No direct security-sensitive surfaces (auth, crypto, permissions) are touched. The main risks are protocol-correctness ones: era-lock state handling, version negotiation error shapes (-32022), and ensuring server-initiated requests stay refused on modern connections. These are interop/correctness concerns rather than injection or data-exposure risks.
Level of scrutiny
High. Server.run is the entry point for every stdio and stream-pair deployment, and the era-lock state machine (unlocked → legacy/modern, lock-on-success semantics, never-lock-on-rejection) directly affects compatibility with released clients. The PR description makes interop claims (released auto-negotiating clients silently upgrade; fallback predicates keyed on error codes) that a maintainer familiar with the client release history should validate, and the deliberate choice to make SSE and in-memory entries dual-era is a design decision worth explicit sign-off. There is also a known capability-advertisement gap (subscriptions/listen advertised but rejected over stream pairs) deferred to follow-up.
Other factors
The bug-hunting pass found no issues, and the new code is well covered by tests (era lock in both directions, cross-era rejections, -32022 round-trips, notification routing, server-initiated request refusal, and a real MCPServer.run('stdio') exchange). Still, the change is large and central enough that it falls well outside the simple/mechanical category eligible for auto-approval.
Motivation
A modern client over stdio had no path to the 2026-07-28 protocol. The stream-pair loop driven by
Server.runonly understood theinitializehandshake: aserver/discoverprobe was rejected, soClient(mode='auto')silently fell back to the legacy era, and a pinned-2026 client failed outright. The modern per-request-envelope path existed only on the single-exchange HTTP entry.What this does
Server.runnow drives a newserve_dual_era_loopdriver — the stream-pair counterpart of the HTTP era router. Era is a property of the connection, decided by how the client opens it; mid-stream switching is undefined, so the first era-distinctive message locks it (matching the typescript-sdk):initializelocks legacy. The connection runs the existing loop runner byte-identically for its lifetime. Modern envelope traffic afterwards is rejected withINVALID_REQUEST._metaenvelope triple (orserver/discover, a modern-only method) locks modern. Every request is validated by the existing transport-agnosticclassify_inbound_requestladder and served single-exchange viaserve_onewith a born-ready per-requestConnection— the same dispatch model as the modern HTTP entry, so-32022version negotiation comes for free. A laterinitializegetsUNSUPPORTED_PROTOCOL_VERSIONnaming the supported modern versions (mirroring the HTTP modern entry).Modern stream-pair connections get a notify-only standalone outbound: server notifications ride the duplex pipe (something stateless HTTP can't offer), while server-initiated requests are refused on both the standalone and request-scoped channels, as 2026-07-28 requires. Client-side cancellation works unchanged —
notifications/cancelledis consumed at the dispatcher layer, so the stdio-only cancellation path works at the modern era for free.The streamable-HTTP manager calls
serve_loopdirectly and is untouched. Note thatServer.runalso serves SSE and in-memory stream pairs, so those entries are dual-era too — deliberate, since the era doctrine is transport-class-generic for duplex pipes, and SDK SSE clients always runmode='legacy'.Interop
server/discoverprobe has always carried the envelope triple in the request body (not just the HTTP header), so released auto-negotiating clients silently upgrade to 2026-07-28 against this server.-32022is special-cased), so the new rejection shapes cannot strand older clients; a test pins this.Known gap (follow-up)
subscriptions/listenis rejected withMETHOD_NOT_FOUNDon the stream-pair modern path for now: the registered listen handler assumes the HTTP entry's stream semantics and would misbehave over a duplex pipe. Capability derivation is handler-keyed and transport-blind, soserver/discoverstill advertises subscription capabilities — wiring listen over stdio (and reconciling that advertisement) is follow-up work.Tests
Driver-level (era lock, both cross-era rejections,
-32022round-trip, bare-probe rejection, ping neutrality, listen containment, notify-over-pipe, server-initiated request refusal), client end-to-end over a realServer.runstream pair (auto, pinned-modern, and legacy modes), and a process-stdio test drivingMCPServer.run("stdio")through the full probe → feature-request exchange.The pre-existing
run("stdio")tests closed stdin immediately after writing the request, racing the dispatcher's EOF-time cancellation of in-flight handlers (a latent flake). They now gate stdin EOF on the responses having arrived, like a real client; the family passes a 30x stress run.Note: the conformance suite currently has no stdio scenarios (it is HTTP-only), so SDK tests carry the coverage here — worth a conformance-repo issue for stdio-modern scenarios.
AI Disclaimer