Skip to content

Concurrent requests sharing a JSON-RPC id on one session receive each other's responses #3137

Description

@touchedtone

Concurrent requests sharing a JSON-RPC id on one session receive each other's responses

Repo: modelcontextprotocol/python-sdk
Versions: reproduced on mcp 1.28.1 (latest) with fastmcp 3.4.4, and on mcp 1.26.0 with fastmcp 3.2.0. Python 3.14, macOS + Linux.
Transport: streamable HTTP, stateful (default)

Summary

In stateful streamable HTTP, the server registers each in-flight request's response stream in a per-session dict keyed by the bare JSON-RPC request id. In _handle_post_request (src/mcp/server/streamable_http.py), both the JSON-response and SSE branches do this:

request_id = str(message.id)
...
self._request_streams[request_id] = anyio.create_memory_object_stream[EventMessage](
    REQUEST_STREAM_BUFFER_SIZE
)
request_stream_reader = self._request_streams[request_id][1]

The assignment is unconditional. If a second request arrives on the same session with an id that is already in flight, it silently overwrites the first request's stream entry. When the first request's handler completes, the response is routed to whatever stream currently sits at that key:

if request_stream_id in self._request_streams:
    await self._request_streams[request_stream_id][0].send(EventMessage(message, event_id))

The result is that one caller receives another caller's response payload, and the rightful caller's POST hangs until it times out.

JSON-RPC 2.0 requires ids to be unique only within a client's own request stream. Any component that multiplexes multiple logical clients over one MCP session — a gateway, a proxy, a connector with a shared session pool — will produce colliding ids as normal operation, because each client leg numbers its own requests independently from 1.

Reproduction

server.py:

import time
from fastmcp import FastMCP

mcp = FastMCP("crossing-repro")

@mcp.tool()
def alice() -> str:
    """Finishes first (1s)."""
    time.sleep(1)
    return "SECRET-BELONGING-TO-ALICE"

@mcp.tool()
def bob() -> str:
    """Finishes later (3s)."""
    time.sleep(3)
    return "SECRET-BELONGING-TO-BOB"

if __name__ == "__main__":
    mcp.run(transport="http", host="127.0.0.1", port=8177, show_banner=False)

client.py — one session, two concurrent tools/call POSTs sharing id=2. Leg A calls alice at t=0; leg B calls bob at t=0.3s:

import json, threading, httpx

BASE = "http://127.0.0.1:8177/mcp"
HEADERS = {"Content-Type": "application/json",
           "Accept": "application/json, text/event-stream"}
COLLIDING_ID = 2

def payloads(resp):
    msgs = [json.loads(l[6:]) for l in resp.text.splitlines() if l.startswith("data: ")]
    out = []
    for msg in msgs:
        try:
            out.append(msg["result"]["content"][0]["text"])
        except (KeyError, IndexError, TypeError):
            out.append(json.dumps(msg))
    return out

with httpx.Client(timeout=12) as c:
    r = c.post(BASE, headers=HEADERS, json={
        "jsonrpc": "2.0", "id": 1, "method": "initialize",
        "params": {"protocolVersion": "2025-06-18", "capabilities": {},
                   "clientInfo": {"name": "repro", "version": "0"}}})
    h = dict(HEADERS, **{"mcp-session-id": r.headers["mcp-session-id"]})
    c.post(BASE, headers=h, json={"jsonrpc": "2.0",
                                  "method": "notifications/initialized"})

    results = {}
    def leg(name, tool, delay):
        import time as t
        t.sleep(delay)
        try:
            rr = c.post(BASE, headers=h, json={
                "jsonrpc": "2.0", "id": COLLIDING_ID, "method": "tools/call",
                "params": {"name": tool, "arguments": {}}})
            results[name] = (tool, rr.status_code, payloads(rr))
        except httpx.ReadTimeout:
            results[name] = (tool, "TIMEOUT", [])

    ts = [threading.Thread(target=leg, args=("leg A", "alice", 0.0)),
          threading.Thread(target=leg, args=("leg B", "bob", 0.3))]
    for t_ in ts: t_.start()
    for t_ in ts: t_.join()
    for name in ("leg A", "leg B"):
        tool, status, body = results[name]
        print(f"{name} called {tool!r:8} -> HTTP {status}; "
              f"received: {body[0] if body else '(nothing — request hung)'}")

Actual output

leg A called 'alice'  -> HTTP TIMEOUT; received: (nothing — request hung)
leg B called 'bob'    -> HTTP 200;     received: SECRET-BELONGING-TO-ALICE

Leg B asked for bob and was handed alice's return value. Leg A never received anything. Identical output on mcp 1.28.1 / fastmcp 3.4.4 and on 1.26.0 / 3.2.0.

Expected

Each POST receives the response to the request it sent. If the server cannot support a duplicate in-flight id on one session, it should reject the second request explicitly (e.g. an -32600 error naming the collision) rather than silently rerouting a response to the wrong caller.

Why this matters

Beyond the correctness bug, on a multi-tenant or gateway-fronted server the misrouted payload is another caller's data, delivered to a caller that never requested it and cannot tell it is wrong. There is no error, no warning, and nothing in the response that identifies the mismatch — the payload is a well-formed JSON-RPC result carrying the colliding id. We hit this in production: a tool call returned a completely unrelated tool's receipt from a different logical session, and the only reason it was caught is that the payload shape was obviously wrong for the call that was made. A same-shaped response would have passed silently.

Workaround (verified)

Running the server in stateless mode gives each request its own transport, so the shared per-session registry never comes into play. Same repro, FASTMCP_STATELESS_HTTP=true:

leg A called 'alice'  -> HTTP 200; received: SECRET-BELONGING-TO-ALICE   [OK]
leg B called 'bob'    -> HTTP 200; received: SECRET-BELONGING-TO-BOB     [OK]

This is only available to servers that don't need stateful features (server-initiated messages, resumability, subscriptions).

Related

#1764 touches the same _request_streams registration but is a different failure mode (zero-buffer deadlock, since fixed by REQUEST_STREAM_BUFFER_SIZE). The id-collision misrouting described here is unaffected by that fix and reproduces on current main's logic.

Suggested fix

Either:

  1. Key the registry by something unique per request rather than the raw JSON-RPC id — e.g. (stream/transport identity, request id), or an internally-minted token. This preserves current behavior for well-behaved single-client sessions and fixes multiplexed ones.
  2. Reject the collision explicitly — if request_id is already present in _request_streams, return a JSON-RPC error for the second request instead of overwriting. Less useful for gateways, but it converts silent data misrouting into a loud, diagnosable failure.

Option 1 is preferable: id collisions across independent client legs are legal JSON-RPC, so a gateway shouldn't have to rewrite ids to stay correct.

I'm happy to open a PR for either approach if you'd like to indicate a preference.

Activity

  1. added a commit that references this issue on Jul 24, 2026
    0aea6ac
  2. tirthfx commented on Jul 25, 2026

    @tirthfx

    I'd like to take this one. Traced it to _handle_post_request in src/mcp/server/streamable_http.py — both the JSON-response branch and the SSE branch assign self._request_streams[request_id] = ... unconditionally, with no check for an existing in-flight entry. A second request colliding on the same id silently overwrites the first's stream, which is how the response gets misrouted.

    _request_streams is keyed directly by the client-supplied RequestId, and that type is also exposed on the public close_sse_stream(request_id: RequestId) API — so decoupling the internal routing key from the client id would touch public API surface and felt like a bigger call than mine to make unilaterally.

    I implemented and verified the narrower, non-breaking option instead: both branches now check if request_id in self._request_streams before registering a new entry, and return a 409 CONFLICT JSON-RPC error ("Duplicate request id: a request with this id is already in flight on this session") instead of overwriting. No change to _request_streams's type or any public signature.

    Added two regression tests (test_post_duplicate_request_id_rejected_while_first_still_in_flight for SSE mode, test_json_response_duplicate_request_id_rejected_while_first_still_in_flight for JSON mode) — each fires a slow request, sends a second with a colliding id while the first is still in flight, asserts the second gets 409 and the first still completes normally with its own correct result. I confirmed both tests fail (asserting 200 instead of 409) with the fix reverted, so they're actually exercising the bug.

    Full scoped suite passes (67/67), ruff/pyright clean.

    Opened a PR with this: #3163. Happy to adjust the approach if you'd prefer the deeper redesign of the internal routing key instead — wanted to lay out the reasoning here in case that's useful context alongside the diff.

  3. maxisbey commented on Aug 14, 2026

    @maxisbey
    Contributor

    Thanks for the detailed repro. This is the same unconditional _request_streams[request_id] overwrite already tracked in #2655 (and also reported in #3060), so I'm going to close this one and keep the discussion in #2655 to avoid splitting it. Your write-up of the gateway/multiplexing angle and the two fix options is useful context there, so feel free to link or copy it across.

    Feel free to reopen if you think this covers something #2655 doesn't.

    AI Disclaimer

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions