anthropics / anthropics/claude-code

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

Đang mở
#91,958 0 bình luận 0 reaction 0 người được giao Xem trên GitHub
area:mcp bug has repro platform:macos
Ngôn ngữ chính
Python
Star
145k
Fork
23.1k
Chỉ số merge pull request
Chỉ số pull request đang chờ

Mô tả

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

Hướng dẫn đóng góp

Chưa lập chỉ mục được hướng dẫn đóng góp cho kho mã nguồn này

Hướng nghiên cứu

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.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
python
Lĩnh vực
api, networking
Loại issue
Lỗi
Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức độ hoạt động
Sôi nổi
Độ rõ ràng
Khá rõ ràng
Mức phù hợp với người mới
48/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.