Skip to content

coerce_request_id() folds non-canonical numeric strings, conflating wire-distinct JSON-RPC ids #3432

Open

Description

@2sumtech

Initial Checks

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 key

Python & MCP Python SDK

Python 3.11.14, python-sdk main @ 5bc9e07, macOS

Activity

  1. added
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Sep 2, 2026
  2. Huang-404-Q commented on Sep 5, 2026

    @Huang-404-Q

    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.

    JSONRPCId in the TS SDK is number | string, and both a bare 10 and
    the string "1_0" arrive as distinct values on the wire — the
    correlation map there keys on the value as-is. The folding in
    coerce_request_id() is purely a Python int() artifact:

    • int("1_000") == 1000 (Python accepts underscores in int literals and,
      since 3.6, in the string form of int() too — JS Number("1_000") is
      NaN, so the TS SDK never merges these)
    • int(" 7 ") == 7 (whitespace tolerance; JSON string ids are compared
      verbatim in TS)
    • int("007") == 7 (the canonical-stringified-int case the function
      does intend to catch — but "007" is also a distinct string id on
      the wire unless the peer actually sent 7 and stringified it, which a
      receiver cannot distinguish after the fact)

    Since the result feeds _pending, _in_flight, and progress-token
    routing, the merge means a notifications/cancelled for one id can
    cancel 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 10 and "1_0" (details in thread if useful
    for 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" -> 7 test intent) would keep the intended stringified-int
    behavior while leaving 007/+7/1_000/7/Arabic-digit forms as
    the distinct string ids they are on the wire.

  3. jagadeepmamidi commented on Sep 5, 2026

    @jagadeepmamidi

    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.

  4. amasen02 commented on Sep 5, 2026

    @amasen02

    Confirming the root cause and wire-level impact here.

    In JSON-RPC 2.0, string and integer identifiers are distinct types. coerce_request_id() in src/mcp/shared/dispatcher.py currently applies Python's int(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 of coerce_request_id(), these wire-distinct requests collapse onto the same internal key. A notifications/cancelled targeting request ID "1_000" can inadvertently cancel request ID 1000, while the true in-flight request remains uncancelled.

    The minimal fix is to enforce the round-trip invariant str(parsed) == request_id when 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.

  5. 2sumtech commented on Sep 5, 2026

    @2sumtech
    Author

    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.

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 workingv1Affects the v1.x maintenance linev2Affects 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