Late response to cancelled MCP request drops stdio transport instead of being ignored
Description
When an MCP server sends a JSON-RPC response for a request that the client has already cancelled via notifications/cancelled, Claude Code logs Received a response for an unknown message ID, closes the stdio transport, and reconnects.
The cancellation spec says senders should silently ignore these:
The sender of the cancellation notification SHOULD ignore any response to the request that arrives afterward
Reproduction
Any MCP server using mcp Python SDK 1.27.0 (which itself violates the receiver-side SHOULD by sending a response on cancel — separate issue against the SDK). Configure the server in ~/.claude.json, invoke a long-running tool (e.g. anything that does await asyncio.sleep), cancel it.
Sample log lines from ~/Library/Caches/claude-cli-nodejs/.../mcp-logs-<server>/<session>.jsonl:
Tool 'poll_build' failed after 57s: MCP error -32001: user-cancel
STDIO connection dropped after 279s uptime
Connection error: Received a response for an unknown message ID:
{"jsonrpc":"2.0","id":3,"error":{"code":0,"message":"Request cancelled"}}
Closing transport (stdio transport error: Error)
Impact
- Every cancelled MCP tool call costs 1–2 reconnects (~5–10s each, more if the server image has
--pull alwaysset). - Heavy use of long-running MCP tools puts the server in a near-permanent reconnect loop.
- Defends against malformed servers (the spec puts the SHOULD-not-send burden on the receiver, but resilient sender behaviour is also called out).
Suggested fix
When a JSON-RPC response arrives with an id that's no longer in the in-flight map, log at debug and drop the message instead of treating it as a transport error. Reserve the transport-drop behaviour for actual protocol violations (malformed JSON, etc.).
Environment
- Claude Code latest (Opus 4.7)
- macOS, stdio transport
- MCP server: any using
mcppython SDK 1.27.0
6 Comments
This is a good report because it identifies a protocol-boundary overreaction. A late response to a cancelled request is exactly the kind of thing a robust client should treat as stale noise, not as a fatal transport condition.
The spec quote matters here: once cancellation has been sent, responses that arrive afterward should be ignored. So even if the server-side SDK is imperfect and still emits a response, the Claude Code client should degrade gracefully rather than dropping the whole stdio connection.
Right now the blast radius is too large:
That turns a routine async race into a connection-level incident.
This is another example of boundary handling being the real product. In asynchronous systems, late, duplicate, or stale messages are normal. The client has to tolerate them without treating them as existential protocol failure.
As a workaround for anyone hitting this in production: mcp-stdio v0.7.0 sits between Claude Code and the HTTP MCP server over stdio and drops any late JSON-RPC response whose id was cancelled via
notifications/cancelled. That turns this canceller-side SHOULD violation into a no-op on the wire — Claude Code never sees the late response, so the stdio transport stays up and the reconnect loop never starts.How it works:
notifications/cancelledis seen,params.requestIdis added to a thread-safe, TTL-bounded (60 s) set. The cancel itself is still forwarded upstream so a spec-compliant server stops its work.idis in the cancel set, it's dropped before reaching stdout. Notifications, server-initiated requests (e.g.sampling/createMessage), and mcp-stdio's own error responses are never filtered.Enabled by default;
--no-cancel-filteropts out for debugging raw upstream traffic. Works for both Streamable HTTP and the legacy SSE transport. One caveat — if the response arrives over the wire before Claude Code has handed the cancel to stdin (possible on SSE due to the async reader thread), the filter can't help because the cancel hasn't been registered yet; the fix for that race lives on the Claude Code side.The workaround we used was to monkey patch the behaviour. Adding an extra dependency or service isn't a useful solution for us.
hit this exact pattern running python SDK MCP servers on macOS for a desktop agent. the real cost isn't the 5-10s reconnect, it's that in-flight requests queued behind the dropped transport get silently re-dispatched on the new connection, which makes bugs look non-deterministic across runs. the monkey-patch fixes the symptom but we ended up adding a small seen-ids ring buffer client-side with a 2 minute TTL so stale ids from any source just drop at debug level. handles the mcp 1.27 case, and also catches servers that respond twice due to their own retry logic. the bigger principle: anything async across a stdio boundary needs to treat stale messages as normal, not as a protocol incident.
Thanks for the report. This is the same issue as #50095 (unknown-ID JSON-RPC response drops stdio transport). Consolidating tracking there.
This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.