Skip to content

Serve the 2026-07-28 protocol over stdio: decide the era from the opening request#3152

Open
maxisbey wants to merge 2 commits into
mainfrom
stdio-serve-2026
Open

Serve the 2026-07-28 protocol over stdio: decide the era from the opening request#3152
maxisbey wants to merge 2 commits into
mainfrom
stdio-serve-2026

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

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/listen was therefore hard-refused over stdio with -32601, even though the same connection advertised tools.listChanged: true. The same rule meant a legacy initialize arriving while a slow 2026 request was in flight got accepted and locked the connection out from under that request.

What changed

serve_dual_era_loop now decides the era from the client's first request, once, before any handler runs, and replays that frame into the chosen serving loop:

  • a request carrying the 2026-07-28 per-request _meta envelope opens a 2026 connection;
  • anything else (the initialize handshake, which doesn't exist at 2026 versions even when a client stamps the envelope on it) opens a 2025 one;
  • a later claim from the other era 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.

With the era no longer waiting on completions, the subscriptions/listen refusal 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

  • Listen works over stdio (previously -32601).
  • initialize during an in-flight 2026 request: refused -32022; the running request finishes. (Previously accepted, re-locking the connection.)
  • A failed 2026 opening probe (unknown method, unsupported version, malformed envelope) still leaves the connection on 2026, so a follow-up initialize gets -32022 — which is the spec's own "this server is modern, use a listed version" signal — rather than running the handshake.
  • server/discover sent without the envelope is 2025-era vocabulary: it answers -32601 and 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.py are updated to the new rule (two that encoded "the era locks on the first request to succeed" are gone; one now drives an actual subscriptions/listen over 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.md asks 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

… 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.
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3152.mcp-python-docs.pages.dev
Deployment https://f09c254c.mcp-python-docs.pages.dev
Commit c3b0e01
Triggered by @maxisbey
Updated 2026-07-23 15:31:33 UTC

Comment thread src/mcp/server/runner.py Outdated
Comment thread docs/whats-new.md
Comment thread src/mcp/server/runner.py
Comment thread src/mcp/server/runner.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp/server/runner.py Outdated
Comment thread src/mcp/server/runner.py Outdated
…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.
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.

1 participant