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.
Activity
Ben-Home commentedon Oct 30, 2026
riszhkejksnd commentedon Oct 31, 2026
Hi I'd like to work on this issue . Are you already working on a pr for the fix or would you be open to someone else working on it?
WingedGuardian commentedon Oct 31, 2026
I'm not. Fine by me!