The wire dispatcher registered each inbound request in `_in_flight` with a
blind overwrite keyed by request id (the `TODO(maxisbey)` from modelcontextprotocol#3046). Two
concurrent requests sharing a JSON-RPC id on one session would silently
displace each other: the older entry was evicted at registration, so a
`notifications/cancelled` for that id always targeted the newer request and
the older one became uncancellable.
Reject a duplicate id that is still in flight with INVALID_REQUEST instead of
overwriting, matching the guard `direct_dispatcher` already applies to
caller-supplied ids. Ids remain reusable once the earlier request completes,
which deployed clients that send a constant id rely on. With duplicate
registration ruled out, the completion path no longer needs the identity guard
on its `_in_flight` pop.
Replaces the two overwrite-semantics tests with three covering rejection,
cancellation-targeting of the original request, and sequential id reuse.
Fixes the dispatcher-layer half of modelcontextprotocol#3060; complementary to the transport-level
guard in modelcontextprotocol#3063.
Motivation and Context
Fixes the dispatcher-layer half of #3060, complementary to the transport-level guard in #3063.
JSONRPCDispatcher._dispatch_request registered every inbound request in _in_flight with a blind overwrite keyed by request id — the TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit rejecting with INVALID_REQUEST added in #3046. On current main, two concurrent requests carrying the same JSON-RPC id on one session silently displace each other:
@Sammy-Dabbas's #3063 fixes the streamable-HTTP transport, where the slot overwrite happens at POST-handling time before the dispatcher is reached. This change fixes the dispatcher itself, which covers the other transports plus the cancellation-targeting case, as discussed on the issue.
The direct_dispatcher already rejects duplicate in-flight ids for caller-supplied ids (request id ... is already in flight); this makes the wire dispatcher consistent with it.
How Has This Been Tested?
Rejection is checked before any await in _dispatch_request and the read loop dispatches messages sequentially, so a duplicate can never register between the guard and the write — no new race is introduced. Added three tests in tests/shared/test_jsonrpc_dispatcher.py:
Full suite passes (5288 passed) at 100.00% branch coverage; pyright and ruff check/ruff format are clean on the touched files.
Implementation notes
Breaking Changes
Behavior change only for a protocol violation: a second request reusing an id that is still in flight now receives an INVALID_REQUEST error instead of silently overwriting the first request's routing. Sequential id reuse after completion is unchanged.
Types of changes
Checklist