Skip to content

Serve the 2026-07-28 era over stdio and other stream-pair transports#3038

Merged
maxisbey merged 1 commit into
mainfrom
stdio-2026-auto
Jun 30, 2026
Merged

Serve the 2026-07-28 era over stdio and other stream-pair transports#3038
maxisbey merged 1 commit into
mainfrom
stdio-2026-auto

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

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):

  • initialize locks legacy. The connection runs the existing loop runner byte-identically for its lifetime. Modern envelope traffic afterwards is rejected with INVALID_REQUEST.
  • The modern _meta envelope triple (or server/discover, a modern-only method) locks modern. Every request is validated by the existing transport-agnostic classify_inbound_request ladder and served single-exchange via serve_one with a born-ready per-request Connection — the same dispatch model as the modern HTTP entry, so -32022 version negotiation comes for free. A later initialize gets UNSUPPORTED_PROTOCOL_VERSION naming 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/discover probe 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 -32022 is 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.

AI Disclaimer

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.
@maxisbey maxisbey marked this pull request as ready for review June 30, 2026 23:05
@maxisbey maxisbey merged commit e50fb5b into main Jun 30, 2026
34 checks passed
@maxisbey maxisbey deleted the stdio-2026-auto branch June 30, 2026 23:11

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants