coerce_request_id() folds non-canonical numeric strings, conflating wire-distinct JSON-RPC ids #3432
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
2.x (current stable)
Description
On current main, coerce_request_id() in src/mcp/shared/dispatcher.py folds a stringified id to int with a bare int(request_id). That accepts a lot more than a canonical integer string, so ids that are distinct on the JSON-RPC wire collapse onto one correlation key:
coerce_request_id('007') -> 7
coerce_request_id('+7') -> 7
coerce_request_id('1_000') -> 1000
coerce_request_id(' 7 ') -> 7
coerce_request_id('٧') -> 7
This shared key backs _pending (response correlation), _in_flight (cancellation), and progress-token routing. So a peer, or a caller using CallOptions["request_id"], that uses e.g. 10 and "1_0" as two ids has them merged. A notifications/cancelled for one hits the other, and the first can't be cancelled. It's the same cross-wiring class as #3060, but between ids that are genuinely different on the wire.
JSON-RPC 2.0 treats String and Number ids as distinct value types, and the function's own intent (and its test, "7" -> 7) is the canonical "peer stringified an int" case. The forms above aren't that. They're an artifact of Python's int(). The docstring says "matches the TS SDK", but JS Number("1_000") is NaN and Number("0x10") is 16, so today's behavior matches neither.
The narrow fix is to fold only when the string equals str(int(s)), which keeps the intended "7" -> 7 and "-3" -> -3 behavior and leaves every other string a distinct id. I have a patch and a regression test for it (both dispatchers share the function) and I'm happy to open a PR once this is triaged and assigned.
Disclosure: I used an AI coding assistant to help explore the code and draft this. I verified the reproduction and understand the fix myself.
Example Code
from mcp.shared.dispatcher import coerce_request_id
for raw in ("007", "+7", "1_000", " 7 ", "٧"):
print(repr(raw), "->", repr(coerce_request_id(raw)))
# every line prints an int, so all five distinct wire ids share one keyPython & MCP Python SDK
Python 3.11.14, python-sdk main @ 5bc9e07, macOS
Activity
Huang-404-Q commentedon Sep 5, 2026
Confirming the wire-level impact with one more data point: the TS SDK,
which the docstring here claims to match, does not fold these forms.
JSONRPCIdin the TS SDK isnumber | string, and both a bare10andthe string
"1_0"arrive as distinct values on the wire — thecorrelation map there keys on the value as-is. The folding in
coerce_request_id()is purely a Pythonint()artifact:int("1_000")== 1000 (Python accepts underscores in int literals and,since 3.6, in the string form of
int()too — JSNumber("1_000")isNaN, so the TS SDK never merges these)int(" 7 ")== 7 (whitespace tolerance; JSON string ids are comparedverbatim in TS)
int("007")== 7 (the canonical-stringified-int case the functiondoes intend to catch — but
"007"is also a distinct string id onthe wire unless the peer actually sent
7and stringified it, which areceiver cannot distinguish after the fact)
Since the result feeds
_pending,_in_flight, and progress-tokenrouting, the merge means a
notifications/cancelledfor one id cancancel a different in-flight request, and the intended one becomes
uncancellable — same cross-wiring class as #3060, but between ids that
are genuinely distinct on the wire.
Reproduced the dispatch-level collision on current main with two
concurrent requests using
10and"1_0"(details in thread if usefulfor a fix). A strict canonical-form check (e.g. only accept the
str(int)round-trip of the string, per the function's own"7" -> 7test intent) would keep the intended stringified-intbehavior while leaving
007/+7/1_000/7/Arabic-digit forms asthe distinct string ids they are on the wire.
jagadeepmamidi commentedon Sep 5, 2026
Hi @2sumtech, are you still planning to submit the patch for this? If not, I’d be interested in taking it up and adding the regression coverage for both the v1 and v2 lines.
amasen02 commentedon Sep 5, 2026
Confirming the root cause and wire-level impact here.
In JSON-RPC 2.0, string and integer identifiers are distinct types.
coerce_request_id()insrc/mcp/shared/dispatcher.pycurrently applies Python'sint(request_id)directly, which parses non-canonical string representations such as leading zeros ("007"), explicit signs ("+7"), underscores ("1_000"), surrounding whitespace (" 7 "), or non-ASCII numerals ("٧").Because
_pending(response correlation),_in_flight(cancellation), and progress token routing all key on the output ofcoerce_request_id(), these wire-distinct requests collapse onto the same internal key. Anotifications/cancelledtargeting request ID"1_000"can inadvertently cancel request ID1000, while the true in-flight request remains uncancelled.The minimal fix is to enforce the round-trip invariant
str(parsed) == request_idwhen parsing string IDs. This preserves the intended interop for canonical decimal integer strings ("7","-3","0") while leaving non-canonical representations untouched as distinct strings.Verified this round-trip invariant locally with dedicated regression test cases, passing all 28 CI matrix jobs cleanly without any regressions across the dispatcher suite (#3454). Happy to help drive this fix forward if maintainers have capacity to assign.
2sumtech commentedon Sep 5, 2026
Yes, still planning to. The fix and a regression test are ready on my fork at https://github.com/2sumtech/python-sdk/tree/fix/coerce-request-id-canonical. I have held off opening the PR because this repo closes PRs on issues that are not assigned, so I am waiting for a maintainer to assign it. Happy to cover both the v1 and v2 lines when it goes in.