Skip to content

stdio dual-era server refuses initialize after a failed server/discover probe (era latched by the first request) #3851

Open

Description

@kuberaplutus224

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/discover first. 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 the initialize handshake." The fallback MUST
    NOT be keyed to one specific error code.
  • Versioning: "An initialize request 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()  # stdio

drive.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

  1. added
    bugSomething isn't working
    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revision
    v2Affects the v2 line (2.x on main)
    on Nov 13, 2026
  2. techaurah commented on Nov 13, 2026

    @techaurah

    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?

  3. techaurah commented on Nov 13, 2026

    @techaurah

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingspec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions