Initial Checks
Description
On current main (v2, tested at 3a6f299), when a caller supplies its own request id via CallOptions["request_id"] (added in #3046), the dispatcher's minted-id sequence can later land on that same id after the supplied request completes. The session then sends two different requests with the same id, which the spec forbids:
The request ID MUST NOT have been previously used by the requestor within the same session.
(https://modelcontextprotocol.io/specification/2025-11-25/basic#requests, same wording since 2025-03-26. The SDK pins this itself as protocol:request-id:unique in tests/interaction/_requirements.py: "ids are never reused within the session".)
Root cause
In JSONRPCDispatcher.send_raw_request (src/mcp/shared/jsonrpc_dispatcher.py, around line 338):
else:
# Mint past any key a supplied id occupies: the collision error is# reserved for the caller who actually chose the id.request_id=self._allocate_id()
whilerequest_idinself._pending:
request_id=self._allocate_id()
The comment says minting should skip past any supplied id, but the guard only checks self._pending, and pending entries are popped when a request completes (line ~428). So for a supplied integer id (or numeric string, since coerce_request_id folds "2" and 2 into one key): once that request finishes, the monotonic counter eventually reaches the same value and reuses it on the wire.
DirectDispatcher._dispatch_request (src/mcp/shared/direct_dispatcher.py, around line 253) has the same pattern with self._in_flight_ids, which is discarded in the finally.
Why it matters
The receiving side is allowed to assume per-session id uniqueness. The SDK's own stateful Streamable HTTP server already mishandles duplicate ids (#3060, response cross-wiring), so an innocent client that supplies small integer ids for a few calls and then keeps using the same session can hit that server-side behavior through no fault of its own. The SDK-internal user of this feature (the subscriptions/listen driver's "listen-N" ids) is immune because non-numeric string ids never collide with the minted integer sequence, so this only bites users of the new public CallOptions["request_id"] with integer or numeric-string ids.
Note this is only about the dispatcher's own minting crossing a completed supplied id. The reverse direction (a caller re-supplying an id it already used before) is currently allowed on purpose and asserted by tests, so I left that alone.
Proposed fix
When accepting a supplied id, advance the mint counter past its coerced key so minted ids can never revisit it:
# jsonrpc_dispatcher.py, after computing pending_key for a supplied id:ifisinstance(pending_key, int):
self._next_id=max(self._next_id, pending_key)
and the same in direct_dispatcher.py with in_flight_key. Happy to open a PR with regression tests for both dispatchers if this looks right.
Example Code
importanyiofrommcp_typesimportJSONRPCRequest, JSONRPCResponsefrommcp.shared.jsonrpc_dispatcherimportJSONRPCDispatcherfrommcp.shared.messageimportSessionMessageasyncdefmain() ->None:
c2s_send, c2s_recv=anyio.create_memory_object_stream[SessionMessage|Exception](4)
s2c_send, s2c_recv=anyio.create_memory_object_stream[SessionMessage|Exception](4)
client=JSONRPCDispatcher(s2c_recv, c2s_send)
asyncdefon_request(ctx, method, params):
return {}
asyncdefon_notify(ctx, method, params):
passwire_ids= []
asyncdefrespond() ->None:
asyncforwireinc2s_recv:
ifisinstance(wire, SessionMessage) andisinstance(wire.message, JSONRPCRequest):
wire_ids.append(wire.message.id)
awaits2c_send.send(
SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=wire.message.id, result={}))
)
asyncwithanyio.create_task_group() astg:
awaittg.start(client.run, on_request, on_notify)
tg.start_soon(respond)
awaitclient.send_raw_request("ping", None, {"request_id": 2})
awaitclient.send_raw_request("ping", None)
awaitclient.send_raw_request("ping", None)
tg.cancel_scope.cancel()
print("wire request ids in order:", wire_ids)
# prints: wire request ids in order: [2, 1, 2]# the third request (dispatcher-minted) reuses the completed supplied id 2anyio.run(main)Python & MCP Python SDK
python: 3.14.5
mcp: main @ 3a6f2996 (v2 development line)
AI disclosure, since it's policy here: I leaned on Claude Code to help navigate the codebase and pressure-test this analysis, but I ran and verified the repro myself and I understand the fix I'm proposing. Freshman in college trying to contribute something genuinely useful, so if I've misjudged the intent behind the mint loop I'd honestly love the correction :)
Initial Checks
Description
On current
main(v2, tested at 3a6f299), when a caller supplies its own request id viaCallOptions["request_id"](added in #3046), the dispatcher's minted-id sequence can later land on that same id after the supplied request completes. The session then sends two different requests with the same id, which the spec forbids:(https://modelcontextprotocol.io/specification/2025-11-25/basic#requests, same wording since 2025-03-26. The SDK pins this itself as
protocol:request-id:uniqueintests/interaction/_requirements.py: "ids are never reused within the session".)Root cause
In
JSONRPCDispatcher.send_raw_request(src/mcp/shared/jsonrpc_dispatcher.py, around line 338):The comment says minting should skip past any supplied id, but the guard only checks
self._pending, and pending entries are popped when a request completes (line ~428). So for a supplied integer id (or numeric string, sincecoerce_request_idfolds"2"and2into one key): once that request finishes, the monotonic counter eventually reaches the same value and reuses it on the wire.DirectDispatcher._dispatch_request(src/mcp/shared/direct_dispatcher.py, around line 253) has the same pattern withself._in_flight_ids, which is discarded in thefinally.Why it matters
The receiving side is allowed to assume per-session id uniqueness. The SDK's own stateful Streamable HTTP server already mishandles duplicate ids (#3060, response cross-wiring), so an innocent client that supplies small integer ids for a few calls and then keeps using the same session can hit that server-side behavior through no fault of its own. The SDK-internal user of this feature (the
subscriptions/listendriver's"listen-N"ids) is immune because non-numeric string ids never collide with the minted integer sequence, so this only bites users of the new publicCallOptions["request_id"]with integer or numeric-string ids.Note this is only about the dispatcher's own minting crossing a completed supplied id. The reverse direction (a caller re-supplying an id it already used before) is currently allowed on purpose and asserted by tests, so I left that alone.
Proposed fix
When accepting a supplied id, advance the mint counter past its coerced key so minted ids can never revisit it:
and the same in
direct_dispatcher.pywithin_flight_key. Happy to open a PR with regression tests for both dispatchers if this looks right.Example Code
Python & MCP Python SDK
AI disclosure, since it's policy here: I leaned on Claude Code to help navigate the codebase and pressure-test this analysis, but I ran and verified the repro myself and I understand the fix I'm proposing. Freshman in college trying to contribute something genuinely useful, so if I've misjudged the intent behind the mint loop I'd honestly love the correction :)