Skip to content

streamable HTTP: integer and string request ids with the same text share one response stream; a string id can displace the GET stream #3786

Open

Description

@WingedGuardian

Summary

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):

  • v2.3.0: request_id = str(message.id) (:642), target_request_id = str(message.id) (:1066), target_request_id = str(related_request_id) (~:1083), and GET_STREAM_KEY = "_GET_stream" (:62);
  • 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.

  1. Send two concurrent requests with ids 77 (integer) and "77" (string), each to a slow tool.
  2. Cancel only the integer 77 (notifications/cancelled with requestId: 77).
  3. 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.
  4. 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

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Oct 29, 2026
  2. Ben-Home commented on Oct 30, 2026

    @Ben-Home
  3. riszhkejksnd commented on Oct 31, 2026

    @riszhkejksnd

    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?

  4. WingedGuardian commented on Oct 31, 2026

    @WingedGuardian
    Author

    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?

    I'm not. Fine by me!

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