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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions