Repository navigation
Description
Summary
On stdio, serve_dual_era_loop fixes the connection's protocol era from the
first request it reads. If that first request is a 2026-07-28 server/discover
probe that the server rejects, the connection is still latched to the modern
era, and the client's spec-described fallback to initialize on the same process
is refused with -32022 "connection is serving the 2026-07-28 protocol; the initialize handshake is not accepted".
The same server answers initialize normally when it is the first request, so it
does support 2025-11-25. It just can't be reached after a failed probe.
Spec (2026-07-28)
- stdio, Backward Compatibility: a dual-version client SHOULD probe with
server/discoverfirst. If "the server returns any other error [than a
recognized modern error], or does not respond within a reasonable timeout: the
server is legacy. Fall back to theinitializehandshake." The fallback MUST
NOT be keyed to one specific error code. - Versioning: "An
initializerequest selects legacy semantics, scoped to the
stdio process", and "A dual-era server MAY serve both eras concurrently on the
same endpoint or process." - Base protocol, Statelessness: "Servers MUST NOT rely on prior requests over
the same connection to establish context (e.g., capabilities, protocol
version, client identity)."
The spec doesn't say whether the fallback reuses the process or starts a new
one. But a client that follows the stdio text literally sends initialize next
on the same process, and a rejected request arguably shouldn't establish the
connection's version.
Reproduction (mcp 2.3.0, Python 3.12)
server.py:
from mcp.server import MCPServer
app = MCPServer("era-latch-repro")
@app.tool()
def ping() -> str:
return "pong"
if __name__ == "__main__":
app.run() # stdiodrive.py (one stdio process: a probe the server rejects with a non-modern
error, then the fallback initialize):
import json, subprocess, sys
META = "io.modelcontextprotocol/protocolVersion"
msgs = [
# Envelope missing clientCapabilities -> the SDK answers -32602 (a non-modern error)
{"jsonrpc": "2.0", "id": 1, "method": "server/discover",
"params": {"_meta": {META: "2026-07-28"}}},
{"jsonrpc": "2.0", "id": 2, "method": "initialize",
"params": {"protocolVersion": "2025-11-25", "capabilities": {},
"clientInfo": {"name": "repro", "version": "0"}}},
]
p = subprocess.Popen([sys.executable, "server.py"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
for m in msgs:
p.stdin.write(json.dumps(m) + "\n"); p.stdin.flush()
print("<<", p.stdout.readline().strip())
p.stdin.close(); p.wait(timeout=5)Output:
<< {"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"params._meta is missing the required envelope key(s): io.modelcontextprotocol/clientCapabilities"}}
<< {"jsonrpc":"2.0","id":2,"error":{"code":-32022,"message":"connection is serving the 2026-07-28 protocol; the initialize handshake is not accepted","data":{"supported":["2026-07-28"],"requested":"2025-11-25"}}}
Control: sending only the initialize (no probe) on a fresh process returns a
normal result with "protocolVersion":"2025-11-25".
A legacy-only server built with the SDK (one whose request handling refuses
modern versions before initialize) hits the same latch: the refused probe fixes
the era, and the fallback initialize is rejected.
Expected
A first request that the server rejects shouldn't decide the era. Two options:
latch only on a request that succeeds, or still accept initialize while no
modern request has been answered successfully. Either would let the stdio
fallback work on the same process.
Secondary observation
The -32022 data lists "supported": ["2026-07-28"] (both on the refused
initialize and on a well-formed probe with an unknown version), although the
same server answers initialize at 2025-11-25 when that comes first. The
spec's example of this error lists both eras (["2026-07-28", "2025-11-25"]).
Code
src/mcp/server/runner.py: serve_dual_era_loop / _replay_from_opening_request
decide opens_modern from the opening request alone, and _serve_modern_stream
raises UNSUPPORTED_PROTOCOL_VERSION for any later initialize. The docstring
says this is intentional ("A later claim from the other era is refused"), so this
may be a design question rather than a bug. Raising it because of the spec's
stdio fallback text.
Activity
techaurah commentedon Nov 13, 2026
Hi, I'd like to look into this: the dual-era stdio server latching the modern era on a rejected server/discover probe, so the spec-described initialize fallback is refused. I'll reproduce it and share the findings here. Could you assign this to me?
techaurah commentedon Nov 13, 2026
Reproduced with mcp 2.3.0 using the reporter's exact drive.py, unchanged: the probe gets -32602 (missing clientCapabilities envelope key), then initialize gets -32022 'connection is serving the 2026-07-28 protocol; the initialize handshake is not accepted'. Mechanism on current main: serve_dual_era_loop in src/mcp/server/runner.py decides the era from the opening request alone — opens_modern is true whenever the first request is not initialize and carries the modern _meta envelope, regardless of whether that request is then served or rejected. The rejected probe still latches the connection to modern, and the modern loop refuses initialize. Per the 2026-07-28 spec's stdio Backward Compatibility text, a client whose probe is rejected with a non-modern error falls back to initialize on the same process, and a rejected request arguably shouldn't establish the connection's version (Base protocol Statelessness: servers must not rely on prior requests to establish context). Fix direction: only latch modern once the opening request is accepted as modern, or let the modern loop accept an initialize fallback when the opening request errored. Happy to implement on assignment.
media-ctrl commentedon Nov 14, 2026
The diagnosis holds up at the code level:
serve_dual_era_loopdecides the era from the shape of the opening frame before it has answered anything (src/mcp/server/runner.py:628-630):So the probe's answer never participates in the decision. What the connection does with that frame afterwards is the part I think splits in two, and the two cases deserve different treatment. Which one you hit depends on the error your probe returned, and that code is worth adding to the report because it is the whole difference between a client bug and a server bug:
Case 1: the probe was answered
-32022. Then the latch is right and theinitializerefusal is intended behaviour. The Backward Compatibility text hangs the fallback on the one code a probing client must not treat as "fall back", so a client that sees-32022should pick a listed version rather than fall back.test_dual_era_loop_unsupported_modern_version_gets_the_supported_liststates exactly that.Case 2: the probe was rejected with anything else.
_has_modern_envelopedocuments that the version key alone is the signal: "a half-built envelope still routes modern and gets the classifier's INVALID_PARAMS naming the missing key" (runner.py:504-513). A client that does not know the exact envelope key names sends precisely that frame, getsINVALID_PARAMS(orHEADER_MISMATCHfrom rung 2 ofclassify_inbound_request), reads it per the spec as "this server is legacy", falls back toinitialize, and is refused by a connection that is already committed to the other era. That is a real defect: the spec says fall back on every error except the one code, and the server cannot honour the fallback because it committed on framing rather than on an outcome.A fix that stays inside the existing structure: run
classify_inbound_requestover the peeked opening frame before choosing, and route to_serve_modern_streamonly when it returns a route. On a rejection that is not-32022, replay into_serve_legacy_stream, where the enveloped probe is answeredINVALID_REQUESTby the legacy guard (runner.py:741-746). That answer is also not-32022, so the client'sinitializeis accepted on the same process and the spec's "MAY serve both eras concurrently" holds. The well-formed discover case is unaffected, sotest_dual_era_loop_discover_locks_modern_and_serves_envelope_requestskeeps passing; the case to add is "half-built envelope probe, then initialize on the same stdio process".Happy to send that as a patch with both tests if the direction looks right.
dcode commentedon Nov 14, 2026
A real client that hits this: Goose 1.57.0. Its rmcp
ClientLifecycleMode::Autosends an envelopedserver/discoverfirst. On any JSON-RPC error that isn't a modern rejection, it falls back toinitializeon the same stdio process.Our case is a server that deliberately stays in the handshake era, because Goose doesn't open
subscriptions/listen(aaif-goose/goose#13055). We removed theserver/discoverhandler. The probe got -32601, and the fallbackinitializegot -32022. Goose then dropped the extension at startup with "process quit before initialization".Our workaround calls the private
mcp.server.runner._serve_legacy_streamdirectly. That refuses the probe with -32600 and accepts theinitialize, and Goose connects on 2025-11-25.Beyond the fix proposed here, a public way to limit which eras a stdio server serves would make this supportable. Related: #3862 (handshake-era
notify_tools_changed()is a no-op).