Serve the 2026-07-28 protocol over stdio: decide the era from the opening request#3152
Serve the 2026-07-28 protocol over stdio: decide the era from the opening request#3152maxisbey wants to merge 4 commits into
Conversation
… opening request The stdio driver derived a connection's protocol era from whichever request finished first, so it could never host a request that does not return: subscriptions/listen was hard-refused with -32601 even though the same connection advertised list-changed capabilities, and a legacy handshake arriving during a slow 2026 request was accepted and locked the connection out from under it. Decide the era from the client's first request instead, once, before any handler runs: a request carrying the 2026-07-28 per-request envelope opens a 2026 connection, anything else (the initialize handshake) opens a 2025 one, and the deciding frame is replayed into the chosen serving loop. A conflicting later claim is refused in one place (initialize on a 2026 connection gets -32022 naming the served versions; an enveloped request on a handshake connection gets -32600). The listen refusal is deleted; the handler was always transport-agnostic.
📚 Documentation preview
|
There was a problem hiding this comment.
All reported issues were addressed across 3 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…ening peek - The opening-request replay now carries each frame's captured sender context, so the streams the SSE transport hands the loop keep their contextvars propagation; pinned by a test on the dual-era loop. - Bound how many frames may precede the client's first request; past the limit the loop stops looking for an opening request and serves a handshake connection, so a peer streaming pre-request frames cannot grow the buffer without bound. - Drop the stale docs admonition that stdio still rejects listen, and trim the era-decision docstring to the rule it now implements.
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
- Frames arriving ahead of the client's first request are kept only up to a small bound and never decide the era: past the bound they are dropped while the loop keeps waiting for the opening request, so a bare notifications/initialized still opens the gate and a flood cannot grow the buffer or pick an era. - Check the captured sender context with `is not None`, since an empty contextvars.Context is falsy. - Word the legacy refusal to the actual rule (first request carried no 2026 envelope) and update the Server.run docstring and one test to the decide-from-the-opening-request model.
- Treat a read stream whose receive end the transport closed as end-of-input in the opening peek and the relay, mirroring the tolerance the dispatcher's read loop already had; the read loop moved here, so its EOF handling moves with it. Two tests close the stream before and after the first request and expect a clean return. - ListenHandler's docstring no longer claims it needs SSE mode. - The legacy refusal states the era rule instead of narrating what the first request was, so it holds for every opener.
There was a problem hiding this comment.
No new issues this round — all three prior review rounds are verified addressed in the latest commits, but this rewrites the era-decision core of the stdio serving path (new peek/replay/relay concurrency machinery), so it warrants a human pass before merge.
What was reviewed this run:
- The a0d6233 fixes: ClosedResourceError-as-EOF containment now present at both read-stream iteration sites (with tests for teardown before and mid-connection), the ListenHandler docstring restriction reworded, and the legacy refusal message now mode-descriptive and accurate in every opening corner (ping, bare discover, envelope-stamped initialize, no-request).
- Earlier-round fixes re-checked: sender-context forwarding through the replay uses an explicit is-not-None check, the pre-request lead is bounded without deciding the era, and the stale lock-on-success prose is gone from Server.run and the test names.
- The headline listen-over-stdio path: the new test drives ListenHandler over the stream pair through ack and graceful close; stream ownership (write stream closed on every exit, replay channel closed in the finally) checks out.
Extended reasoning...
Overview
The PR replaces serve_dual_era_loop's lock-on-first-success era model with decide-from-the-opening-request: a new _replay_from_opening_request context manager peeks the client's first request, a bounded lead of pre-request frames is retained, and a relay task replays the stream (sender contexts included) into either _serve_legacy_stream or _serve_modern_stream. The subscriptions/listen stdio refusal is deleted, docs and the ListenHandler docstring are updated, and the dual-era test suite is rewritten to the new rule. Six files touched; the substantive change is concentrated in src/mcp/server/runner.py.
Security risks
No auth, crypto, or permission surfaces are touched. The era-decision signal is the spec-reserved io.modelcontextprotocol/protocolVersion meta key, which legacy traffic cannot mint, and cross-era claims are refused with the spec's own error codes. The pre-request frame flood is bounded (_PRE_REQUEST_REPLAY_LIMIT), so a client cannot grow the server's buffer with leading notifications. The one context-propagation subtlety (an empty-but-valid sender context being discarded by a truthiness check, which could have leaked serve-task contextvars into handlers) was caught in round two and fixed with an explicit None check.
Level of scrutiny
High. This is production-critical protocol routing for every stdio and in-memory connection, and the new implementation is concurrency-heavy: a peek loop, a zero-buffer replay channel, a relay child task in a task group, and ownership handoffs of both streams. Three review rounds surfaced real issues (sender-context loss through the replay, receive-end-close tolerance, several diagnostics describing the removed model), all fixed and test-pinned — which is evidence the review earned its depth, and also exactly the profile of change that should get a human maintainer's judgment on the design (e.g. the deliberate choice that a failed 2026 probe still fixes the connection modern, and the author's declining of the debug-mode BrokenResourceError containment).
Other factors
Test coverage is strong: the dual-era suite was systematically rewritten, the straddle case (initialize during an in-flight modern request) is pinned, listen is driven end-to-end over the stream pair, and both teardown-as-EOF paths have tests. The author states 100% branch coverage, pyright and ruff clean. Two open process items argue for human sign-off regardless: the PR description flags that the upstream conformance suite may lack a listen-over-stdio test (AGENTS.md requires one for new 2026 features), and the behavior changes are user-visible protocol semantics that a maintainer should own.
Supersedes #3151, which grew far beyond the actual fix. This one does one thing.
Why
The stdio driver derived a connection's protocol era from whichever request completed first, so it could never host a request that doesn't return.
subscriptions/listenwas therefore hard-refused over stdio with-32601, even though the same connection advertisedtools.listChanged: true. The same rule meant a legacyinitializearriving while a slow 2026 request was in flight got accepted and locked the connection out from under that request.What changed
serve_dual_era_loopnow decides the era from the client's first request, once, before any handler runs, and replays that frame into the chosen serving loop:_metaenvelope opens a 2026 connection;initializehandshake, which doesn't exist at 2026 versions even when a client stamps the envelope on it) opens a 2025 one;initializeon a 2026 connection gets-32022naming the served versions, an enveloped request on a handshake connection gets-32600.With the era no longer waiting on completions, the
subscriptions/listenrefusal is simply deleted; the handler was always transport-agnostic and now serves over the stream pair like any other 2026 request. No public API changes: same driver signature,Server,MCPServer, the dispatcher, and the transports are untouched.Observable corners that move
-32601).initializeduring an in-flight 2026 request: refused-32022; the running request finishes. (Previously accepted, re-locking the connection.)initializegets-32022— which is the spec's own "this server is modern, use a listed version" signal — rather than running the handshake.server/discoversent without the envelope is 2025-era vocabulary: it answers-32601and a fallback handshake still lands on the same connection (previously-32602, same client outcome).Not in scope
The trailing
{"code": 0, "message": "Request cancelled"}frame the dispatcher writes after a client cancel is a separate stdio conformance item and stays as it is here.Testing
The dual-era tests in
tests/server/test_runner.pyare updated to the new rule (two that encoded "the era locks on the first request to succeed" are gone; one now drives an actualsubscriptions/listenover the stream pair through ack and graceful close; the straddle case is pinned). Full suite green with 100% branch coverage; pyright and ruff clean.One process note:
AGENTS.mdasks that new 2026 features carry a matching conformance-suite test. I don't believe the upstream conformance suite covers listen over stdio yet; flagging that here rather than skipping it.AI Disclaimer