You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
streamable HTTP: integer and string request ids with the same text share one response stream; a string id can displace the GET stream #3786
StreamableHTTPServerTransport keys its per-request response streams by str(request_id). JSON-RPC and MCP allow a request id to be an integer or a string, and an integer 77 and a string "77" are different ids. After str() they map to the same key, so two valid, distinct in-flight requests on one session share one response stream.
The same string namespace holds the standalone GET stream's sentinel key, GET_STREAM_KEY = "_GET_stream". A request whose id is the string "_GET_stream" replaces the GET stream's registration.
This is distinct from #3060 / #2655 / #3137, which cover a client reusing the same id. Here the ids are different under the spec and still collide.
Where
src/mcp/server/streamable_http.py on v1.30.0 and v2.3.0 (latest at time of writing):
v1.30.0: the same sites at ~:599, ~:1063 and ~:1076, and :63.
Reproduction
Use the stateful streamable HTTP server, unmodified SDK, a real TCP client, and one protocol session.
Send two concurrent requests with ids 77 (integer) and "77" (string), each to a slow tool.
Cancel only the integer 77 (notifications/cancelled with requestId: 77).
Observed: the HTTP stream of the string"77" request receives integer 77's cancellation response. The string request's own handler was not cancelled, but its response is lost or misdelivered.
Sentinel variant: with a GET stream open, send a request with id "_GET_stream". Its POST registration replaces the GET stream's. The next server notification is lost, and a second GET is accepted (200) instead of being refused (409).
Matrix (JSON and SSE response modes × normal completion and cancellation × 7 id patterns):
All 12 collision cells misroute: integer/string in both orders, plus the zero case 0 and "0".
All 16 controls deliver correctly: integer-only, string-only, distinct mixed ids, and strings that cannot collide.
Expected
Responses route to the request with the exact same typed id. A string id never displaces the GET stream.
Proposed fix
Derive the internal stream key with one stateless, injective request-id-to-key helper, used at all three conversion sites, and keep a separate namespace for the GET sentinel. Wire ids stay unchanged.
Public close_sse_stream(request_id) should keep accepting the raw request id. A private helper should close by derived key. Replacing str() with json.dumps() alone looks like a fix, but it breaks raw-id close_sse_stream for string ids.
A PR with this change and regression tests follows.
Thorough report. The additions worth making are two consequences you did not list, one second collision channel, and a detail on the fix.
Consequences beyond the lost response:
The event store inherits the collision. In the message router the derived key is what goes to event_store.store_event(...) before delivery, so a replay after reconnect attributes the event to the colliding stream key. A client reconnecting with Last-Event-ID replays into the wrong request's stream, and that misattribution outlives the connection that caused it.
In a gateway that multiplexes callers onto one session, request ids come from the callers. Two callers can independently choose 77 and "77", so this is not only self-inflicted: one caller's response can be delivered to another caller's stream, and a caller-chosen id of "_GET_stream" can displace server notifications. That moves the GET sentinel half from cosmetic to an integrity issue.
The second collision channel is at the related-id site. You cited a third conversion site as target_request_id = str(related_request_id), and in v1.30.0 that is str(session_message.metadata.related_request_id) (line 1076). That value comes from server-side metadata, not from the client's id space, so a server that stamps related_request_id as integer 77 collides with a client request whose id is the string "77". Same defect, different origin, and it means the fix has to cover all three sites rather than only the two that route a response.
On the fix, one addition to your note about json.dumps. You are right that swapping str() for json.dumps() alone breaks close_sse_stream for raw string ids, because the public method receives the wire id and would have to re-derive. A helper plus a separate close path is the right shape, and the sentinel does not need to share the string namespace at all:
_GET_STREAM=object() # identity key, unreachable from any client valuedef_stream_key(request_id): return (type(request_id).__name__, request_id)
A tuple key keeps the value itself for equality, so 77 and "77" differ by the first element while the wire id stays inspectable in debug output. An object() sentinel makes the second half unrepresentable rather than merely unlikely, which is the difference between a fix and a mitigation.
If you add one cell to the matrix, make it the related-id channel: a response whose related_request_id is integer 77 arriving while a request with string id "77" is in flight. It needs no new harness and it tests the site your proposed helper would have to cover anyway.
Activity
Ben-Home commentedon Oct 30, 2026
Thorough report. The additions worth making are two consequences you did not list, one second collision channel, and a detail on the fix.
Consequences beyond the lost response:
The second collision channel is at the related-id site. You cited a third conversion site as
target_request_id = str(related_request_id), and in v1.30.0 that isstr(session_message.metadata.related_request_id)(line 1076). That value comes from server-side metadata, not from the client's id space, so a server that stamps related_request_id as integer 77 collides with a client request whose id is the string "77". Same defect, different origin, and it means the fix has to cover all three sites rather than only the two that route a response.On the fix, one addition to your note about json.dumps. You are right that swapping str() for json.dumps() alone breaks close_sse_stream for raw string ids, because the public method receives the wire id and would have to re-derive. A helper plus a separate close path is the right shape, and the sentinel does not need to share the string namespace at all:
A tuple key keeps the value itself for equality, so 77 and "77" differ by the first element while the wire id stays inspectable in debug output. An object() sentinel makes the second half unrepresentable rather than merely unlikely, which is the difference between a fix and a mitigation.
If you add one cell to the matrix, make it the related-id channel: a response whose related_request_id is integer 77 arriving while a request with string id "77" is in flight. It needs no new harness and it tests the site your proposed helper would have to cover anyway.