Serve the 2026-07-28 protocol over stdio: decide the era from the opening message

Closed
MA
4mo ago
+4088-1750

Status: opened for review while two things finish on the branch: a rebase onto
current main (this branch predates #3143, so it currently conflicts in the
classification files), and a cleanup that moves listen-stream state off the handler
and the server-level close_subscriptions() verb (details at the bottom). Both
land as follow-up pushes here.

Motivation

The 2026-07-28 protocol did not work over stdio, and when I dug in it came down to one refused
feature plus a handful of conformance defects that all shared a root cause. A connection's
protocol era (the 2025 initialize handshake versus the 2026 self-contained per-request
envelope) was being derived after the fact from which requests had completed, in several
places, instead of being decided once, in wire order, from how the client opened the connection.
That model couldn't host a request that never returns, so subscriptions/listen was
hard-refused over stdio with -32601 even though the same connection advertised
tools.listChanged: true; a slow modern request left the connection "unlocked" so a legacy
handshake arriving mid-flight was accepted and re-locked the connection out from under the
running call; and a peer cancel produced a trailing {"code": 0, "message": "Request cancelled"} frame that the stdio binding forbids ("MUST NOT send any further messages" for a
cancelled request).

The listen handler was never the problem. Served through a driver that decides the era from
the opening message, the unmodified handler produces the exact spec flow over a pipe (ack first,
id-stamped events, graceful close, silence after a client cancel). So this change is not "make
listen work on stdio"; it is "decide the era once, in the right place, and delete the code that
was compensating for deciding it wrong."

What changed

One stream driver, era decided in the read loop. serve_dual_era_loop and serve_loop are
replaced by a single serve_stream(server, read, write). The connection's era is decided
synchronously, inside the JSON-RPC dispatcher's sequential read loop, before the request body is
spawned and before the next frame is read, so it is ordered by the wire and by nothing else (no
appeal to task scheduling anywhere). initialize or any envelope-less request opens the legacy
era; an enveloped request opens the modern era; an enveloped server/discover is a probe that
is answered but pins nothing, so a client that probes and falls back to initialize still
finds the handshake available; a stray leading notification opens nothing. A second,
conflicting era claim on a committed connection is refused rather than silently switching (a
legacy initialize on a modern connection gets -32022 naming the served versions; an
enveloped request on a handshake connection gets -32600). Both eras are always served; there
is no server-level switch to disable one, matching the other SDKs.

Cancel silence by construction. Each request now answers through a one-shot channel whose
write target is swapped for a powerless void by a peer cancel, by the request's own terminal
write, and by the peer going away. There is no if cancelled at any write site. The commit
point is one synchronous step (leave the cancellable table and take the write target, no
checkpoint between), which gives a clean rule for the race: if the cancel is read before the
body returned, nothing is written; if the body had already returned, the owed answer is written
exactly once even when parked on backpressure. That satisfies both the stdio MUST NOT and the
cancellation pattern's "MAY ignore a cancel whose processing has completed" at the same time.

The dispatcher knows less MCP than before. The generic JSON-RPC dispatcher loses the code-0
cancel frame and the code-0 str(exc) catch-all (both legacy-era vocabulary in plumbing that
shouldn't know eras exist) and the inline_methods={"initialize"} knob. It gains two
MCP-agnostic mechanisms: an optional receive-order admission gate on run(admit=) and the
request channel. The Dispatcher contract's OnRequest/OnNotify are now documented as invoked
synchronously in receive order, with the returned awaitable as the body; every existing
async def handler already satisfies that, since calling an async def does no work. This is
the same shape as the go-sdk's jsonrpc2 Preempter (a pre-queue admission hook), so it's
generic JSON-RPC prior art rather than an MCP intrusion.

Author-facing surface. Server.run(read, write) now stands alone
(initialization_options is optional). Server.lifespan() is a bound zero-argument context
manager, so the odd server.lifespan(server) self-reference is gone. New: serve_listener(server, listener), the socket-shaped host that enters the lifespan once and drives every accepted
connection, and mcp.server.stdio.newline_json_transport(byte_stream), the newline-delimited
JSON-RPC framer for any byte stream. The custom-transport docs are now a four-rung ladder from
server.run() up to composing server.lifespan() + serve_stream(..., lifespan_state=)
yourself, with the enter-the-lifespan-once rule stated on the rung where it bites.

Other observable changes (all in the migration guide with before/after): the discovery
probe no longer pins; a modern request needs only the required _meta pair (clientInfo is
optional); unmapped handler exceptions become -32603 "Internal server error" with the text
kept in the server log rather than on the wire; MCPServer prompt errors are -32602 with the
authored message; at stdin EOF the server drains gracefully (open listens end with their result)
instead of writing CONNECTION_CLOSED per id onto a closing pipe; over legacy streamable HTTP
in JSON mode a peer-cancelled POST now completes as an empty 202 Accepted instead of lingering
until the client disconnects.

Migration

Everything is grouped in docs/migration.md; the short version:

  • serve_loop(...) / serve_dual_era_loop(...) become server.run(read, write) for a single
    connection, or serve_stream(..., lifespan_state=) when connections share a lifespan you
    entered yourself.
  • server.lifespan(server) becomes async with server.lifespan() as state: (a TypeError
    otherwise). The constructor's lifespan= callable is unchanged.
  • If you implement your own Dispatcher, note the documented obligation that handlers are
    invoked synchronously in receive order and return the body awaitable, and that
    JSONRPCDispatcher(inline_methods=) is gone (state hold=(method == "initialize") in your
    own admission via run(admit=) if you need the handshake to hold the loop).
  • Code that hand-sent notifications/cancelled while still awaiting the request now gets no
    response for the cancelled id (that's the spec); code that read exception text off code=0
    errors should raise MCPError to put a deliberate message on the wire.
  • MCPServer.get_prompt() raises MCPError (was ValueError) for its own prompt errors.

Testing

  • New permanent suites, all in-repo and running on both asyncio and trio: a wire-ordering
    suite (era decided by the opening message, the straddle refused, probe-then-fallback,
    stray-notification and claim-less-request rules, cancel silence), and an 18-case cancel-race
    suite pinning every direction of the cancel-versus-answer race, the EOF cases, and the
    owner-teardown cases, with no fixed sleeps. Plus a streamable-HTTP suite for the JSON-mode
    cancelled-POST completion, and the existing stream-driver, stdio, and dispatcher suites
    parametrized over both anyio backends.
  • Full suite green with 100% branch coverage; strict-no-cover clean; pyright and ruff clean;
    no new # pragma: no cover, # type: ignore, or # noqa in src/.
  • End to end over real stdio pipes with raw newline-delimited JSON-RPC: listen open with
    concurrent traffic, published event, client cancel with no trailing frame, server-initiated
    graceful close, legacy handshake during in-flight modern work, pair-only envelope routed
    modern, late handshake refused -32022, and probe-then-fallback.
  • Interop against the TypeScript SDK over stdio in both directions: its client in auto mode
    against this server (probe to DiscoverResult to modern requests, ack-first stamped listen and
    cancel silence, and a forced probe timeout landing on a working legacy handshake on the same
    connection), and this client against its server.

Still landing on this branch

  • Rebase onto current main now that #3143 has merged (the envelope work overlaps the same
    classification code), which also retires the interop caveat about the previously required
    clientInfo.
  • Moving open listen-stream state off the ListenHandler and dropping the server-level
    close_subscriptions() verb: streams belong to the connection that opened them (and end with
    it), the handler is stateless, and a graceful close-everything is the subscription bus's job.
    Today, as on main, a Server shared across connections ends every connection's listen
    streams when one connection closes; this fixes that by scope rather than by caveat.

Out of scope, deliberately: making one publish reach a 2026 listen stream and a 2025 client
(feeding legacy peers from the subscription bus) is a separate follow-up, since it changes what
a legacy handshake advertises and delivers.

AI Disclaimer