Title: streamable HTTP (stateful): concurrent requests with duplicate JSON-RPC ids cross-wire responses — one request receives another's payload, the other hangs · Issue #3060 · modelcontextprotocol/python-sdk · GitHub
Open Graph Title: streamable HTTP (stateful): concurrent requests with duplicate JSON-RPC ids cross-wire responses — one request receives another's payload, the other hangs · Issue #3060 · modelcontextprotocol/python-sdk
X Title: streamable HTTP (stateful): concurrent requests with duplicate JSON-RPC ids cross-wire responses — one request receives another's payload, the other hangs · Issue #3060 · modelcontextprotocol/python-sdk
Description: Summary In the stateful streamable-HTTP server transport, two concurrent POSTs on the same session that carry the same JSON-RPC request id cross-wire: the later request receives the earlier request's response (entire envelope, wrong arit...
Open Graph Description: Summary In the stateful streamable-HTTP server transport, two concurrent POSTs on the same session that carry the same JSON-RPC request id cross-wire: the later request receives the earlier request...
X Description: Summary In the stateful streamable-HTTP server transport, two concurrent POSTs on the same session that carry the same JSON-RPC request id cross-wire: the later request receives the earlier request...
Opengraph URL: https://github.com/modelcontextprotocol/python-sdk/issues/3060
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"streamable HTTP (stateful): concurrent requests with duplicate JSON-RPC ids cross-wire responses — one request receives another's payload, the other hangs","articleBody":"### Summary\n\nIn the stateful streamable-HTTP server transport, two concurrent POSTs on the same session that carry the **same JSON-RPC request id** cross-wire: the later request receives the earlier request's response (entire envelope, wrong arity and all), the later request's own response is silently dropped, and the earlier request's SSE stream hangs forever (keep-alive pings defeat client read timeouts).\n\nDuplicate in-flight ids violate the spec (\"The request ID MUST NOT have been previously used by the requestor within the same session\"), but a real production client — **claude.ai's custom-connector MCP client sends every request with `id: 1`** — triggers this constantly, and the server neither rejects nor tolerates the violation: it silently mis-routes user data. We observed one user's tool response delivered to a different conversation's request in production before isolating this mechanism.\n\n### Mechanism (v1.27.0; the same code is present on current `main`)\n\n`mcp/server/streamable_http.py`:\n\n1. POST handler (~L534–537): `request_id = str(message.root.id)` then `self._request_streams[request_id] = anyio.create_memory_object_stream(...)` — the routing table is keyed **by request id alone**. A second concurrent POST with the same id **silently overwrites** the first's slot; the first POST's reader keeps a now-orphaned stream.\n2. `message_router` (~L997–1045): the handler's response is routed by `response_id` to whichever POST currently owns the slot — i.e. the **latest** arrival, regardless of which request it answers.\n3. `sse_writer` cleanup (~L597–616): after delivering one response the slot is popped, so the second response finds no slot and is dropped (debug log: `Request stream 1 not found`).\n\nResult for two overlapping requests A (id=1, slow) then B (id=1): **B receives A's response; B's response is dropped; A hangs indefinitely.**\n\n`mcp/shared/session.py` (~L375) has the same single-key assumption in `_in_flight[responder.request_id]`, which additionally breaks cancellation targeting under duplicate ids.\n\n### Reproduction\n\nToy server (echo tool with random 0–2s sleep):\n\n```python\n\"\"\"repro_server.py\"\"\"\nimport random\nimport anyio\nfrom mcp.server.fastmcp import FastMCP\n\nmcp = FastMCP(name=\"repro\", host=\"127.0.0.1\", port=18999)\n\n@mcp.tool()\nasync def echo(sentinel: str) -\u003e str:\n await anyio.sleep(random.uniform(0.0, 2.0))\n return f\"ECHO:{sentinel}\"\n\nif __name__ == \"__main__\":\n mcp.run(transport=\"streamable-http\")\n```\n\nClient: one initialized session, 12 concurrent `tools/call` POSTs, each with a unique sentinel; mode `same_id` uses `id: 1` for all (mimicking claude.ai), mode `unique_id` is the control:\n\n```python\n\"\"\"repro_client.py — run: python repro_client.py same_id unique_id\"\"\"\nimport asyncio, json, sys\nimport httpx\n\nBASE = \"http://127.0.0.1:18999/mcp\"\nHEADERS = {\"accept\": \"application/json, text/event-stream\", \"content-type\": \"application/json\"}\nN, TIMEOUT = 12, 20.0\n\ndef parse_sse_for_response(text):\n for line in text.splitlines():\n if line.startswith(\"data:\"):\n try:\n obj = json.loads(line[5:].strip())\n except json.JSONDecodeError:\n continue\n if \"result\" in obj or \"error\" in obj:\n return obj\n return None\n\nasync def initialize(client):\n r = await client.post(BASE, headers=HEADERS, json={\n \"jsonrpc\": \"2.0\", \"id\": 0, \"method\": \"initialize\",\n \"params\": {\"protocolVersion\": \"2025-06-18\", \"capabilities\": {},\n \"clientInfo\": {\"name\": \"repro\", \"version\": \"0\"}}})\n r.raise_for_status()\n sid = r.headers.get(\"mcp-session-id\")\n hdrs = {**HEADERS, **({\"mcp-session-id\": sid} if sid else {})}\n r2 = await client.post(BASE, headers=hdrs,\n json={\"jsonrpc\": \"2.0\", \"method\": \"notifications/initialized\"})\n assert r2.status_code in (200, 202)\n return sid\n\ndef _sid_headers(sid):\n return {**HEADERS, **({\"mcp-session-id\": sid} if sid else {})}\n\nasync def call_tool(client, sid, rpc_id, sentinel):\n body = {\"jsonrpc\": \"2.0\", \"id\": rpc_id, \"method\": \"tools/call\",\n \"params\": {\"name\": \"echo\", \"arguments\": {\"sentinel\": sentinel}}}\n try:\n # wall-clock timeout: SSE pings defeat httpx read timeouts on orphaned streams\n r = await asyncio.wait_for(\n client.post(BASE, headers=_sid_headers(sid), json=body, timeout=TIMEOUT),\n timeout=TIMEOUT)\n except (httpx.TimeoutException, asyncio.TimeoutError):\n return (sentinel, \"TIMEOUT\", None)\n obj = parse_sse_for_response(r.text)\n if obj is None or \"error\" in obj:\n return (sentinel, \"OTHER\", r.text[:120])\n got = json.dumps(obj[\"result\"])\n return (sentinel, \"OK\" if f\"ECHO:{sentinel}\" in got else \"SWAPPED\", got[:160])\n\nasync def run_burst(mode):\n async with httpx.AsyncClient() as client:\n sid = await initialize(client)\n tasks = [call_tool(client, sid, 1 if mode == \"same_id\" else 100 + i, f\"SENT_{mode}_{i:02d}\")\n for i in range(N)]\n results = await asyncio.gather(*tasks)\n print(f\"=== mode={mode} ===\")\n print(\"OK=%d SWAPPED=%d TIMEOUT=%d\" % (\n sum(s == \"OK\" for _, s, _ in results),\n sum(s == \"SWAPPED\" for _, s, _ in results),\n sum(s == \"TIMEOUT\" for _, s, _ in results)))\n for sent, status, detail in results:\n if status != \"OK\":\n print(f\" {sent}: {status} {detail or ''}\")\n\nasync def main():\n for mode in sys.argv[1:] or [\"same_id\", \"unique_id\"]:\n await run_burst(mode)\n\nasyncio.run(main())\n```\n\n### Observed results (mcp 1.27.0, Python 3.12, Linux)\n\n| mode | OK | SWAPPED | TIMEOUT |\n|---|---|---|---|\n| `same_id` (12 concurrent, all `id: 1`) | 0 | 1 | 11 |\n| `unique_id` control | 12 | 0 | 0 |\n\nPairwise variant (A slow, B posted 300ms later, both `id: 1`, 10 trials): A **never** receives a response; B receives **A's payload** in 7/10 trials (B's own in the rest). This exactly matched our production incident, including the response-arity mismatch.\n\n`stateless_http=True` is immune by construction (per-request transport): same burst is 12/12 OK.\n\n### Suggested fix\n\nAt minimum, reject a POST whose request id is already in flight on the session with a JSON-RPC `-32600` error instead of silently overwriting the routing slot — a protocol violation should fail loudly, not deliver one user's data to another request. (Queueing/serializing duplicate-id requests would also work and keeps the misbehaving-but-widespread client functional.)\n\nThe TypeScript SDK server has the same unguarded pattern (`_requestToStreamMapping.set(message.id, streamId)`); filing separately there. The client-side id-reuse is also being reported to Anthropic.\n","author":{"url":"https://github.com/scottmsilver","@type":"Person","name":"scottmsilver"},"datePublished":"2026-07-03T17:58:18.000Z","interactionStatistic":{"@type":"InteractionCounter","interactionType":"https://schema.org/CommentAction","userInteractionCount":4},"url":"https://github.com/3060/python-sdk/issues/3060"}
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:3d54fe04-cc99-b956-822c-626d2f08e13e |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | A3B8:25B02F:C13C94:1022A37:6A5BC227 |
| html-safe-nonce | 6492a9fac81abf9609c672f0a11ecaad201b74524b557a09f04d6022cee53f51 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJBM0I4OjI1QjAyRjpDMTNDOTQ6MTAyMkEzNzo2QTVCQzIyNyIsInZpc2l0b3JfaWQiOiIzNDE4NTI2NjIzNzQ1ODA3NzYiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | ea5d1e17d32a3f9869b8f98416ff0dc898d1d1c29b7532d79f4bc7d120eb1753 |
| hovercard-subject-tag | issue:4805450775 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/modelcontextprotocol/python-sdk/3060/issue_layout |
| twitter:image | https://opengraph.githubassets.com/e587e3f18226fe91a6f326fc793fd0fdc8275ff61b5296826f95a343086db87c/modelcontextprotocol/python-sdk/issues/3060 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/e587e3f18226fe91a6f326fc793fd0fdc8275ff61b5296826f95a343086db87c/modelcontextprotocol/python-sdk/issues/3060 |
| og:image:alt | Summary In the stateful streamable-HTTP server transport, two concurrent POSTs on the same session that carry the same JSON-RPC request id cross-wire: the later request receives the earlier request... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | scottmsilver |
| hostname | github.com |
| expected-hostname | github.com |
| None | 5290d7e14309ad1e76106a9c4237bd1041517e83ea182c8ab756752cb0c6940b |
| turbo-cache-control | no-preview |
| go-import | github.com/modelcontextprotocol/python-sdk git https://github.com/modelcontextprotocol/python-sdk.git |
| octolytics-dimension-user_id | 182288589 |
| octolytics-dimension-user_login | modelcontextprotocol |
| octolytics-dimension-repository_id | 862584018 |
| octolytics-dimension-repository_nwo | modelcontextprotocol/python-sdk |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 862584018 |
| octolytics-dimension-repository_network_root_nwo | modelcontextprotocol/python-sdk |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 9c975978430e9ad293956f2bbdaf153b1bd84a99 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width