Serve the 2026-07-28 era over stdio and other stream-pair transports
Motivation
A modern client over stdio had no path to the 2026-07-28 protocol. The stream-pair loop driven by Server.run only understood the initialize handshake: a server/discover probe was rejected, so Client(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.run now drives a new serve_dual_era_loop driver — 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.- The modern
_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). - A rejected classification never locks, so a failed probe leaves the legacy handshake available.
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/cancelled is consumed at the dispatcher layer, so the stdio-only cancellation path works at the modern era for free.
The streamable-HTTP manager calls serve_loop directly and is untouched. Note that Server.run also 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 run mode='legacy'.
Interop
- Zero client changes. The
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. - The released fallback predicate keys on the error code alone (only
-32022is special-cased), so the new rejection shapes cannot strand older clients; a test pins this.
Known gap (follow-up)
subscriptions/listen is rejected with METHOD_NOT_FOUND on 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, so server/discover still advertises subscription capabilities — wiring listen over stdio (and reconciling that advertisement) is follow-up work.
Tests
Driver-level (era lock, both cross-era rejections, -32022 round-trip, bare-probe rejection, ping neutrality, listen containment, notify-over-pipe, server-initiated request refusal), client end-to-end over a real Server.run stream pair (auto, pinned-modern, and legacy modes), and a process-stdio test driving MCPServer.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.