MCP: error reply to server/discover probe is unroutable and kills the connection

Status Open
Reported on v2.1.287
Maintainer reply None cached
Activity 0 comments · opened Oct 8, 2026

Summary

Claude Code sends a server/discover protocol-era probe before initialize. Against a 2025-era Streamable HTTP server, that probe is correctly rejected with -32600 Bad Request: Missing session ID — there is no session yet, so there is no Mcp-Session-Id to send, and the spec requires the server to answer 400.

Because this server does not echo the request id (see root cause below), that rejection cannot be correlated to the pending probe. The probe therefore resolves by timeout rather than by the reply, and the reply itself becomes an orphan. When it lands after the probe window has closed, it surfaces as Received a response for an unknown message ID, is routed to _onerror, and tears down a transport that had just connected successfully. The subsequent tools/list then waits out the full MCP_TIMEOUT on a dead pipe.

Net effect for the user: servers report as connected and then expose no tools, roughly 20% of the time, with a 30–120 s stall each.

Environment

  • Claude Code 2.1.287 → 2.1.294 (Windows 11 Enterprise N, 10.0.26200)
  • 15 MCP servers declared as stdio, bridged to Streamable HTTP by mcp-remote 0.1.49
  • Servers: Python, Server: uvicorn, serverInfo.version 1.26.0, negotiated protocolVersion: 2025-06-18

Symptom

14:12:59.313  Successfully connected in 19294ms
14:12:59.332  STDIO connection dropped after 0s uptime            <- 18 ms later
14:12:59.332  Connection error: Received a response for an unknown message ID
14:14:59.326  Failed to fetch tools: Request timed out            <- 120 s later, on a dead pipe

Across October's logs: 132 STDIO connection dropped after 0s and 132 unknown message ID — a perfect one-to-one pairing — affecting all 15 servers indiscriminately, including those carrying no extra flags.

Reproduction

Any Streamable HTTP server built on the official Python SDK reproduces the server side in one command:

curl -s -i -X POST "http://<mcp-server>/" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":12345,"method":"server/discover","params":{}}'

Response:

HTTP/1.1 400 Bad Request
content-type: application/json
mcp-session-id: 328c435ad74d4ddbbd3da41524f885d5

{"jsonrpc":"2.0","id":"server-error","error":{"code":-32600,"message":"Bad Request: Missing session ID"}}

Note "id":"server-error" — the request id 12345 is not echoed. Same result for any method (tools/list with "id":777 returns the identical body).

Root cause — three independent defects that compound

1. The server mislabels its rejection (upstream, MCP Python SDK)

src/mcp/server/streamable_http.py, _create_error_response:

error_response = JSONRPCError(
    jsonrpc="2.0",
    id="server-error",  # We don't have a request ID for general errors
    error=ErrorData(code=error_code, message=error_message),
)

Present in v1.9.0, v1.13.0 and v1.26.0; replaced by id=None on main. JSON-RPC 2.0 requires null when the request id cannot be determined, never an arbitrary string. This is an upstream SDK issue, not server-author code — but see the section below: fixing it does not fix this bug.

2. The client coerces JSON-RPC ids to Number

From the bundled MCP SDK client in the Claude Code binary:

_onresponse(e) {
  let t = Number(e.id);
  let r = this._requestResolvers.get(t);
  if (r) { /* ... */ return }
  let a = this._responseHandlers.get(t);
  if (a === void 0) {
    this._onerror(Error(`Received a response for an unknown message ID: ${JSON.stringify(e)}`));
    return
  }
  /* ... */
}

Number("server-error") is NaN, so no handler matches and the response goes to _onerror, the connection-error path. JSON-RPC 2.0 permits string ids; coercing with Number() means no string id can ever correlate — including Claude Code's own probe id, which is a string (server-discover-probe-<n>).

3. The late-answer recogniser requires both an echoed id and a success

var Ci = "server-discover-probe-";
// in the transport read buffer:
if (this._anthropicProbeTimedOut === true
    && "id" in n && typeof n.id === "string" && n.id.startsWith(Ci)
    && "result" in n
    && !this.answeredProbeLate)
  this.answeredProbeLate = true, this.onAnsweredProbeLate?.()

The guard requires both the echoed probe id and a result member. Both conditions fail here: the id is server-error, and the payload is an error. A late rejection can therefore never be recognised as a probe answer, and falls through to the generic orphan handling in point 2.

To be precise about the scope of this one: an in-window error reply is consumed by the era classifier when the id is echoed — issue #97391 demonstrates it, since changing only the error message there changes the resulting verdict. The gap is therefore specific to (a) replies that cannot be correlated at all, and (b) the late path, which recognises a success but not an error.

Why the symptom is intermittent

The rejection is never absorbed cleanly; timing only decides whether it is harmful.

  • Arriving inside the probe's wait window: ignored, the probe times out normally, the legacy verdict is reached, initialize succeeds.
  • Arriving after the window: treated as an orphan response on a live transport, and fatal.

We confirmed this by removing a 5.2 s per-server startup cost (replacing npx mcp-remote with a vendored bundle of the same 0.1.49 — 0.538 s vs 5.719 s). Result the next morning: 126 Missing session ID but 0 drops, 0 unknown message ID, 0 Failed to fetch tools. The bug is unchanged — only the margin moved. Any slow server, cold cache or loaded machine re-exposes it.

Dating

  • Missing session ID: 0 occurrences in September, 523 in October; first on 2026-10-02.
  • String-count diff across binaries in ~/.local/share/claude/versions: server/discover goes from 53 to 66 occurrences between 2.1.282 (2026-09-25) and 2.1.287 (2026-10-02) — the exact day of first occurrence.
  • answeredProbeLate / onAnsweredProbeLate first appear in 2.1.292 (2026-10-07), i.e. the failure mode is already known, but the handler only covers a successful late answer.

This cannot be fixed server-side

  • Rejecting the probe is correct: the Streamable HTTP spec requires 400 for a non-initialize request without Mcp-Session-Id.
  • Answering the probe with a result would misrepresent a 2025 server as modern.
  • Upgrading the Python SDK to the id=None fix does not help: Number(null) is 0, which is still uncorrelated (and may collide with a legitimate handler keyed 0).
  • Sending initialize first is circular: the era determines the shape of initialize itself — cf. Request is missing the required _meta envelope for protocol revision 2026-07-28 (io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientCapabilities).

Related

#97391 (area:mcp, platform:macos, open) reports the same server/discover probe against a
different server, with the opposite outcome: there the error reply is correlated and consumed,
but the era is classified as modern / 2026-07-28 even though the probe errored and
initialize answered 2025-11-25. Its author found that changing only the error message
— same -32601 code — flips the verdict to legacy and loads all tools.

The two are complementary rather than duplicates:

| | #97391 | this issue |
|---|---|---|
| Probe reply | -32601, id echoed, in-window | -32600, id not echoed, sometimes late |
| Era verdict | wrong (modern) | correct (legacy), but reached by timeout |
| Symptom | connection stays up, tools/list times out at 30 s | transport torn down 18 ms after connecting |
| Platform | macOS, direct stdio server | Windows, mcp-remote bridge to Streamable HTTP |

Together they suggest the probe's error path is fragile in more than one way: verdict selection
appears to key on the error message rather than the code, and an error reply that cannot be
correlated is treated as a transport fault instead of a probe outcome.

MCP_PROTOCOL_NEGOTIATION=legacy (see Workaround) should also resolve #97391, as it suppresses
the probe entirely.

Suggested fix

  1. Treat an error reply to server/discover as a valid legacy verdict, not as an orphan response — including when the reply cannot be correlated by id — and resolve the probe immediately instead of waiting out its timeout.
  2. During the handshake, never let an uncorrelated response reach _onerror as a connection error — including after the probe window has closed, which is the fatal case here.
  3. Do not correlate JSON-RPC ids via Number(); the spec allows string ids, and Claude Code's own probe uses one.
  4. Optionally, recognise the upstream signature {"id":"server-error","error":{"code":-32600}} the way Claude Code already special-cases another server SDK: "This server (Java MCP SDK 0.17.0 or older) rejected Claude Code's elicitation capability. Set bareElicitationCapability: true for it".

Workaround

{ "env": { "MCP_PROTOCOL_NEGOTIATION": "legacy" } }

Verified effective: the probe is not emitted at all, and Missing session ID, dropped after 0s, unknown message ID and Failed to fetch tools all drop to 0. It is lossless here — 645/645 observed connections already negotiated legacy — but it is a static pin that will silently prevent adoption of a newer era if the servers are later upgraded.

View original on GitHub ↗