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.

  4. media-ctrl commented on Nov 14, 2026

    @media-ctrl

    The diagnosis holds up at the code level: serve_dual_era_loop decides the era from the shape of the opening frame before it has answered anything (src/mcp/server/runner.py:628-630):

    opens_modern = (
        opening is not None and opening.method != "initialize" and _has_modern_envelope(opening.params)
    )

    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 the initialize refusal 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 -32022 should pick a listed version rather than fall back. test_dual_era_loop_unsupported_modern_version_gets_the_supported_list states exactly that.

    Case 2: the probe was rejected with anything else. _has_modern_envelope documents 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, gets INVALID_PARAMS (or HEADER_MISMATCH from rung 2 of classify_inbound_request), reads it per the spec as "this server is legacy", falls back to initialize, 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_request over the peeked opening frame before choosing, and route to _serve_modern_stream only when it returns a route. On a rejection that is not -32022, replay into _serve_legacy_stream, where the enveloped probe is answered INVALID_REQUEST by the legacy guard (runner.py:741-746). That answer is also not -32022, so the client's initialize is accepted on the same process and the spec's "MAY serve both eras concurrently" holds. The well-formed discover case is unaffected, so test_dual_era_loop_discover_locks_modern_and_serves_envelope_requests keeps 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.

  5. dcode commented on Nov 14, 2026

    @dcode

    A real client that hits this: Goose 1.57.0. Its rmcp ClientLifecycleMode::Auto sends an enveloped server/discover first. On any JSON-RPC error that isn't a modern rejection, it falls back to initialize on 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 the server/discover handler. The probe got -32601, and the fallback initialize got -32022. Goose then dropped the extension at startup with "process quit before initialization".

    Our workaround calls the private mcp.server.runner._serve_legacy_stream directly. That refuses the probe with -32600 and accepts the initialize, 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).

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