| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
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...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).
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.
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.
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.
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
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):
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
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.
AI Disclaimer