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_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.
Concurrent requests sharing a JSON-RPC id on one session receive each other's responses
Repo:
modelcontextprotocol/python-sdkVersions: reproduced on
mcp1.28.1 (latest) withfastmcp3.4.4, and onmcp1.26.0 withfastmcp3.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: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:
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:client.py— one session, two concurrenttools/callPOSTs sharingid=2. Leg A callsaliceat t=0; leg B callsbobat t=0.3s:Actual output
Leg B asked for
boband was handedalice's return value. Leg A never received anything. Identical output onmcp1.28.1 /fastmcp3.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
-32600error 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:This is only available to servers that don't need stateful features (server-initiated messages, resumability, subscriptions).
Related
#1764 touches the same
_request_streamsregistration but is a different failure mode (zero-buffer deadlock, since fixed byREQUEST_STREAM_BUFFER_SIZE). The id-collision misrouting described here is unaffected by that fix and reproduces on currentmain's logic.Suggested fix
Either:
(stream/transport identity, request id), or an internally-minted token. This preserves current behavior for well-behaved single-client sessions and fixes multiplexed ones.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.