Repository navigation
Description
Initial checks
- Reproduced on 2.3.0 and on
maind7845c0. - Related, but a different code path:
- streamable HTTP: integer and string request ids with the same text share one response stream; a string id can displace the GET stream #3786 is the Streamable HTTP stream keys (
str(id)). This bug also reproduces over stdio. - coerce_request_id() folds non-canonical numeric strings, conflating wire-distinct JSON-RPC ids #3432 is about which strings
coerce_request_idfolds ("07","+7"). This bug occurs with the canonical"7"the function folds by design. - streamable HTTP (stateful): concurrent requests with duplicate JSON-RPC ids cross-wire responses — one request receives another's payload, the other hangs #3060 and Streamable HTTP server silently drops in-flight request when client reuses a JSON-RPC id #2655 cover a client reusing one id. Here the ids differ.
- streamable HTTP: integer and string request ids with the same text share one response stream; a string id can displace the GET stream #3786 is the Streamable HTTP stream keys (
What happens
JSONRPCDispatcher stores inbound requests as _in_flight[coerce_request_id(req.id)]. With 7 and "7" in flight at once, they share a slot. A notifications/cancelled naming the earlier one cancels the later one, which then never answers, while the earlier one runs to completion and can no longer be cancelled.
Reproduction (stdio, unmodified SDK)
A server whose tool blocks until released, receiving these frames in order:
{"jsonrpc":"2.0","id":"9007199254740991","method":"tools/call","params":{"name":"gate","arguments":{"tag":"g0"}}}
{"jsonrpc":"2.0","id":9007199254740991,"method":"tools/call","params":{"name":"gate","arguments":{"tag":"g3"}}}
{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":"9007199254740991"}}
After both tools are released, "9007199254740991" (the one cancelled) answers and 9007199254740991 gets no response. A stdlib-only script that sends these frames to any stdio server command, and exits 1 on the defect: https://github.com/ashirimi1019/mcp-faultline/blob/main/evidence/fl001/minimized/repro_py_stdio_random_seed_6_min.py
Expected
Cancelling one id never affects another. The TypeScript SDK servers (1.32.2 and 2.4.0) key handlers by the typed id and pass the same scenarios.
Decision needed
Should notifications/cancelled match the request id exactly?
Fixing the slot (exact-id keys) is straightforward. But test_cancelled_correlates_across_string_and_int_request_id_forms pins a tolerance where a cancel for "7" cancels an in-flight 7. That tolerance can't be kept safely: a late cancel for a just-completed "7" (a race the spec requires handling gracefully) is indistinguishable from a stringified cancel for 7. Any fallback, even one that requires a single match, cancels the wrong request in that race. The spec says cancels for unknown or completed ids SHOULD be ignored, and both SDKs' own senders keep the id's type. The analysis is in https://github.com/ashirimi1019/mcp-faultline/blob/main/docs/upstream/exact-id-cancellation.md.
Patch
A candidate change (8 lines in jsonrpc_dispatcher.py, plus regression tests including the late-cancel race) is ready if exact matching is acceptable: https://github.com/ashirimi1019/mcp-faultline/blob/main/docs/upstream/patches/0001-Cancel-only-the-exact-JSON-RPC-request-id.patch
Validated on Ubuntu from source (https://github.com/ashirimi1019/mcp-faultline/actions/runs/41533686584):
- upstream
scripts/testpasses with 100% coverage and strict-no-cover; ruffandpyrightare clean;- typed-id scenarios go from 19/24 to 24/24 over stdio;
- 150 randomised scenarios pass across stdio and HTTP. The HTTP part also needs the streamable HTTP: integer and string request ids with the same text share one response stream; a string id can displace the GET stream #3786 change.
I'd open a PR only if assigned.
AI disclosure: the defect was found by an automated harness, and this report, the patch and its tests were drafted with AI assistance (Claude Code). Results come from the linked CI run.
Activity
Shanmukh-l33t commentedon Nov 3, 2026
Confirming the reproduction independently, in-process against
mainat af4c6f4 (memory-stream harness modeled ontests/server/test_cancel_handling.py, no stdio): with one blockingtools/callat id7(int) and another at id"7"(str) both in flight,notifications/cancellednaming7cancelled the"7"handler. Request7was then answered; request"7"never got a response. So the repro holds at theJSONRPCDispatcherlevel, not just over stdio.Two details from reading the code that bear on the fix design:
The folding is documented as deliberate —
coerce_request_id's docstring says"7"and 7 are one id for correlation purposes, even where the wire carries the verbatim value(matches the TS SDK). So keying_in_flightby verbatim id would change that contract and makenotifications/cancelledcorrelation ambiguous. The invariant that actually breaks is one-entry-per-correlation-id:_in_flight[coerce_request_id(req.id)](jsonrpc_dispatcher.py:608) lets a second concurrent request silently overwrite the first request's entry, which is why the wrong handler is cancelled and the first becomes un-cancellable.The outbound side already refuses exactly this collision —
send_raw_requestraisesValueError(f"request id {request_id!r} is already in flight")when a supplied id collides in_pending, andDirectDispatchermirrors it in its in-memory domain. The inbound side has no equivalent guard.Approach I'd take, for what it's worth: keep the coerced key, but when an inbound request arrives whose coerced key is already in
_in_flight, answer the newcomer with a JSON-RPC error rather than sharing/overwriting the slot — mirroring the outbound duplicate-id behavior and keeping the one-entry-per-correlation-id invariant that cancellation relies on. A regression test would assert (a) the second colliding request receives an error response, (b) the first request remains cancellable and completes normally.Happy to implement this if a maintainer wants an outside PR for it — the reporter has first call either way.
Disclosure: written with AI assistance; the reproduction above was executed locally against
mainbefore posting.media-ctrl commentedon Nov 4, 2026
Confirming the repro and widening it: the collision domain is bigger than
"07"and"+7".coerce_request_id(src/mcp/shared/dispatcher.py:56) isint(request_id)for strings, andint()also accepts leading and trailing whitespace, a leading+, and non-ASCII decimal digits.int("7 "),int(" 7"),int("\u0667")(Arabic-Indic seven) andint("\uff17")(fullwidth seven) all return 7, so all of them share a slot with the integer 7 atjsonrpc_dispatcher.py:608. Any of those spellings can arrive on the wire from a peer.The strongest argument for exact matching is that the coercion buys nothing on this path. The server answers with
dctx._request_id, which isreq.idverbatim, so the inbound table never has to correlate a value the peer transformed. It only exists to make a stringified cancel land, and that is the one place a wrong hit is unrecoverable. The outbound_pendingtable is the opposite case: there the tolerance protects against a peer that stringifies ids on the way back, and it has no cross-request cancellation surface.That split suggests per-table keys rather than one shared function:
_in_flight: key on the typed id ((type(id), id)), cancel on the exact typed id._pending: keep the typed key and fall back to the coerced key only when the typed key misses and exactly one pending entry folds to it.A caveat on the exact-first fallback: it is only safe when the loser of an ambiguity cannot be a live request, and for cancels it always can be. The way to keep late cancels deterministic instead of tolerant is a bounded set of recently completed typed ids, so a cancel for a request that answered one frame ago hits a tombstone and is dropped per the spec SHOULD on unknown or completed ids, rather than being retried against a numeric twin.
Same shape for progress, which your repro does not exercise:
_dispatch_notificationresolvesprogressTokenthroughcoerce_request_idat:651, so concurrent requests using7and"7"share oneon_progresscallback and each one's progress frames reach the other's handler. A typed key fixes both tables with one decision.Separately, the same dict has a second defect your repro does not need: identical ids blind-overwrite the slot. The TODO at
:606-608names it ("duplicate ids blind-overwrite (v1/TS parity); revisit rejecting with INVALID_REQUEST"). A typed key alone leaves that path silent, so the change likely wants-32600for an id already in flight at the same time.test_cancelled_correlates_across_string_and_int_request_id_formsshould be inverted rather than deleted, plus one new case: a cancel for"7"leaves an in-flight7running to completion and answering.1828703450qqcom-ux commentedon Nov 4, 2026
I have a fix ready for this issue in PR #3815 (currently auto-closed pending assignment).
Root cause:
JSONRPCDispatcher._in_flightkeys inbound requests bycoerce_request_id(req.id), which folds"7"and7into one dict slot. Two concurrent requests whose ids differ only in JSON-RPC type blind-overwrite each other, and anotifications/cancellednaming one cancels whichever is stored.Fix: store in-flight requests under the verbatim id; cancellation looks up the exact id first and falls back to coerced/stringified forms so the existing cross-type correlation tests (
test_cancelled_correlates_across_string_and_int_request_id_forms) pass unchanged.Validation: full
tests/shared/test_jsonrpc_dispatcher.py80 passed / 0 failed. New regression testtest_peer_cancel_does_not_cross_request_id_typesfails on main and passes after.Could I be assigned to this issue so the PR can stay open? Thanks!
Shanmukh-l33t commentedon Nov 4, 2026
Confirmed the widening — all five spellings fold to one slot. I ran it directly:
and
coerce_request_id(src/mcp/shared/dispatcher.py:56) isint(request_id)for anystrthat parses, soself._in_flight[coerce_request_id(req.id)](with theduplicate ids blind-overwriteTODO directly above it) keys all of them identically. Thenotifications/progresslookup resolvesprogressTokenthrough the same function againstself._pending, so that's one decision covering both tables, not two.On the split-table shape: the typed key belongs on
_in_flightbecause it's the only table where a wrong hit is unrecoverable — the handler answers withdctx._request_id, set toreq.idverbatim, so a coerced cancel can kill a live request whose response id never matches the key it was cancelled under. Keeping tolerance on_pendingwith exact-key-first and a single-fold-fallback preserves the peer-stringifies-ids case without a cross-request surface.The bounded set of recently-completed typed ids is what makes late cancels deterministic rather than merely unlikely to misfire — without it, a fallback can still land on a numeric twin that happens to be live.
Test shape I'd expect: invert
test_cancelled_correlates_across_string_and_int_request_id_formsinto "a cancel for"7"leaves an in-flight7running and answering", plus the mirrored progress case — concurrent7and"7"requests each keep their ownon_progress. The blind-overwrite →-32600question feels orthogonal to key typing and probably wants its own change.(Written with AI assistance; the code references and the
int()behaviour above were re-verified againstmaintoday.)