anthropics / anthropics/claude-code

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

Ouverte
#91,958 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub
area:mcp bug has repro platform:macos
Langage dominant
Python
Étoiles
145k
Forks
23.1k
Métriques de merge des PR
Métriques de PR en attente

Description

## 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:

```python
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.

Guide de contribution

Aucun guide de contribution indexé pour ce dépôt

Piste de recherche

Start by running the provided FastMCP slow_echo/fast_echo streamable-http reproduction against Claude Code, using a slow call above the client timeout and a concurrent fast call. Trace the client’s JSON-RPC response correlation for one Mcp-Session-Id, especially after the slow call times out. Done means each response remains matched to its own id and tool, with late replies discarded or handled without occupying a sibling’s slot.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Évaluation

Stack technique
python
Domaine
api, networking
Type d'issue
Bug
Difficulté
4/5
Temps estimé
3-5 jours
Activité
Active
Clarté
Plutôt claire
Accessibilité débutants
48/100

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.