MCP streamable-http: a slow tool call's result lands in a sibling call's slot in a concurrent batch

Status Open
Reported on v2.1.185
Maintainer reply None cached
Activity 0 comments · opened Sep 4, 2026

Summary

When Claude Code fires a batch of MCP tools/call requests concurrently on one Mcp-Session-Id over streamable-http and one call is slow, the client sometimes reports it as "Tool call timed out waiting for server response" while its real result lands in a sibling call's slot. This has happened twice in production, both inside a multi-tool batch issued in one turn. The model ends up with a well-formed but wrong result in place of the tool it actually called, with no signal anything is wrong.

Environment

  • Claude Code: 2.1.185
  • OS: macOS 26.5.2
  • Connection: remote MCP server over streamable-http, via the claude.ai connector (not local stdio)
  • Server: FastMCP 3.2.4, MCP Python SDK 1.27.0, behind nginx, OAuth 2.1

Observed

| Date/time (UTC) | Batch size | Reported timed out | Payload landed in | Server evidence |
|---|---|---|---|---|
| 2026-09-03 ~21:11 | 12 | get_tag_vocabulary (fast, DB-only) | get_park | Journal: both handlers ran and completed, get_tag_vocabulary at 21:11:01, get_park at 21:11:02 |
| 2026-09-04 ~00:38 | 3 (get_park, search_waypoints, compute_route) | get_park (slow, external Park Service API) | compute_route | search_waypoints was correct; both other handlers ran server-side; re-issuing compute_route alone later returned the correct result |

Neither incident reproduced on a sequential re-run of the same calls.

Expected

Each concurrent tools/call in a batch should return its own result or its own timeout, matched to its own JSON-RPC id and tool. A slow call's eventual result should never attach to a different call's response, and a call whose handler completed server-side should never be reported as timed out.

Server-side exoneration

We suspected our own MCP server first and ruled it out by driving it over the wire with a hand-rolled streamable-http client, not fastmcp's client library, since the wire protocol needed checking. The client did a real initialize handshake, captured the Mcp-Session-Id, fired concurrent tools/call POSTs on that one session with distinct ids and unique per-call tags, and asserted both the returned id and the echoed tag on every response.

We swept 28 configurations, 5 repetitions each: batch sizes 3 and 12, slow call first, middle, and last, real tools mixed with probe tools, pooled and single shared connections, and a call abandoned mid-flight then a second wave on the same connection, the closest model of the reported timeout. Roughly 1,200 tools/call requests total: zero mis-routed ids, zero mis-routed bodies. We also held the session's standalone SSE GET stream open concurrently and confirmed it carried zero reply frames, so the server never delivers a reply on the wrong channel. The checker was validated with a seeded swap and a seeded wrong-id, confirming it goes red on both before we trusted a passing run.

Hypothesis

The mis-routing looks client-side: correlation of JSON-RPC ids across concurrent tools/call requests on a single Mcp-Session-Id, when one call exceeds the client's per-call timeout. Working theory: once the slow call's real reply arrives after the client already gave up and reported a timeout, the client attaches that late reply to whichever sibling call is still pending in its bookkeeping, instead of discarding it or matching strictly by id.

Minimal reproduction the maintainers can run

We have not run this exact repro against Claude Code; the above is from production observations and the server-side wire test only. This should be enough to test the hypothesis:

from fastmcp import FastMCP
import asyncio

app = FastMCP("repro")

@app.tool()
async def slow_echo(seconds: float, tag: str) -> str:
    await asyncio.sleep(seconds)
    return f"slow:{tag}"

@app.tool()
def fast_echo(tag: str) -> str:
    return f"fast:{tag}"

app.run(transport="streamable-http", path="/mcp")

Connect this server to Claude Code and prompt: "call slow_echo(seconds=N, tag='slow') and fast_echo(tag='fast') in the same turn," with N above the client's per-call tool timeout. Watch whether fast_echo reports a timeout while slow_echo's answer shows up under the wrong tool name, or vice versa.

Workaround

Until fixed, we call external-API-backed tools one at a time rather than batching them with other tools in the same turn, and re-issue the suspect call alone whenever a call times out and a sibling's result looks like the wrong shape.

View original on GitHub ↗