Repository navigation
Concurrent requests sharing a JSON-RPC id on one session receive each other's responses #3137
Description
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:
- 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. - Reject the collision explicitly — if
request_idis 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
fix(streamable-http): keep concurrent requests sharing a JSON-RPC id …
tirthfx commentedon Jul 25, 2026
I'd like to take this one. Traced it to
_handle_post_requestinsrc/mcp/server/streamable_http.py— both the JSON-response branch and the SSE branch assignself._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_streamsis keyed directly by the client-suppliedRequestId, and that type is also exposed on the publicclose_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_streamsbefore registering a new entry, and return a409 CONFLICTJSON-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_flightfor SSE mode,test_json_response_duplicate_request_id_rejected_while_first_still_in_flightfor JSON mode) — each fires a slow request, sends a second with a colliding id while the first is still in flight, asserts the second gets409and the first still completes normally with its own correct result. I confirmed both tests fail (asserting200instead of409) 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.
maxisbey commentedon Aug 14, 2026
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