Repository navigation
Description
Preflight Checklist
- I have searched existing issues and this hasn't been reported yet
- This is a single bug report
- I am using the latest version of Claude Code
What's Wrong?
When a Streamable HTTP MCP connection drops while a tools/call is in flight (MCP error -32000: Connection closed), Claude Code treats the drop as session expiry, re-initializes, and re-sends the same tool call. A dropped connection does not prove the server never received the request. In our case the first POST had already reached the server and the tool had run; the retry ran it a second time.
The model saw exactly one tool_use and one tool_result (the retry's), so it had no way to know the tool executed twice. Our tool runs commands on remote machines: the first run performed a destructive file operation, and the retry ran the same command again against the changed state.
This differs from the recovery added in #55970, which retries after the server answers HTTP 404 for an unknown Mcp-Session-Id. A 404 proves the server rejected the request without running it, so that retry is safe. A connection closed mid-call proves nothing about execution. Related but separate: #95018 (the retry covers only tools/call) and #96733 (two concurrent initializes during recovery).
What Should Happen?
- Retry a
tools/callautomatically only when the failure proves the server did not process it: an HTTP 404 (session not found) answer to the POST, or a transport that was already closed before the request was written. - When the connection drops after the request was sent, return the error to the model instead of re-sending it, worded so the model knows the tool may have run (for example "connection lost after the call was sent; the tool may have executed").
- Optionally, allow the automatic retry for tools whose MCP annotations mark them safe to repeat (
readOnlyHint: trueoridempotentHint: true), and never for tools without them.
Error Messages/Logs
Client MCP log (mcp-logs-<server>/*.jsonl), UTC, server and tool names replaced:
13:54:38.264 Calling MCP tool: <server>__<tool>
13:54:39.056 Tool '<server>__<tool>' failed after 0s: MCP error -32000: Connection closed
13:54:39.056 MCP session expired during tool call (connection closed), clearing connection cache for re-initialization
13:54:39.057 Retrying tool '<server>__<tool>' after session recovery
13:54:39.058 Initializing HTTP transport to https://<gateway>/<server>/mcp
13:54:39.818 Successfully connected (transport: http) in 760ms
13:54:39.819 Calling MCP tool: <server>__<tool>
13:54:43.283 Tool '<server>__<tool>' completed successfully in 3s
Server side, the same minute: the first request arrived about 0.6 s after the call started and ran (queued 13:54:38.834, completed 13:54:40.330). The retried request arrived with byte-identical arguments on a new JSON-RPC id and a new MCP session and ran again (queued 13:54:41.055, completed 13:54:43.112). Two more retries followed the same pattern on another session that morning (13:52:30 and 13:53:29 UTC).
Steps to Reproduce
- Connect Claude Code to a Streamable HTTP MCP server with a tool that has a visible side effect (for example, appends a line to a file and sleeps 2 s).
- Call the tool, and close the HTTP connection from the server or a proxy after the request is received but before the response is written.
- Claude Code logs "MCP session expired during tool call (connection closed)" and "Retrying tool ... after session recovery", then calls the tool again.
- The side effect happened twice; the model sees one successful result.
Claude Model
Not model-specific (client transport behavior).
Is this a regression?
I don't know.
Claude Code Version
2.1.283 (User-Agent claude-code/2.1.283 (sdk-ts, agent-sdk/0.3.260))
Platform
Claude Code through the Agent SDK
Operating System
Linux (Node v26.3.0)
Terminal/Shell
Other
Additional Information
We have worked around it server-side by answering a re-sent call from an intent ledger within a short window. Every server with a non-idempotent tool is exposed until the client stops re-sending after an ambiguous failure.
Activity
fix(proxy): anchor the replay-safe 404 to the transport's own error
fix(proxy): stop re-sending tools/call after a backend timeout (#187)
0xdhx commentedon Oct 22, 2026
Adding a measurement: 2.1.307 ("Fixed HTTP MCP tool calls being sent twice when the connection closed mid-call; only read-only or idempotent tools are now retried") does what this issue asks for, on the recovery path in the logs above.
Setup. A stdlib Python Streamable HTTP MCP server on loopback with three no-argument tools:
poke(no annotations),peek(readOnlyHint: true) andnudge(idempotentHint: true). It logs everytools/callarrival, and on the first call to each tool it ends the POST's SSE response without a result. Oneclaude -pturn per cell (Haiku 4.5), with a throwaway config dir and--strict-mcp-config. I separated client re-sends from the model calling the tool again bytool_useid: a second arrival under onetool_useid is a client re-send. The decisive cells were re-run on the unmodified release binaries.tools/callarrivals at the server for one modeltool_use:poke(no hints)peek(readOnlyHint: true)nudge(idempotentHint: true)2.1.304,
poke: the same sequence as in this issue.2.1.307,
poke: the same "session expired … (connection closed)" line, no retry, and the model receives:That covers all three points under "What Should Happen?": no replay after an ambiguous close, an error that says the tool may have run, and a retry kept only for tools annotated as safe to repeat.
Limits:
connection closed, not how quickly the drop is noticed. The pending-call hang is tracked separately in [BUG] Streamable HTTP MCP: tools/call stays "still running" forever when the server dies mid-call — the client reconnects the GET stream but never fails the pending POST #104166.fix(proxy): stop re-sending tools/call after a backend timeout (metat…
Ben-Home commentedon Oct 27, 2026
The annotation gate removes the reported failure but not the general shape, and that is worth recording before this closes.
readOnlyHintandidempotentHintare server authored claims. The client cannot verify them, which is tolerable forreadOnlyHint(a server that lies about read only is already free to do anything) but weaker foridempotentHint, because idempotence there is a property of the input together with the server's state. A tool shaped like "append the entry if it is not already present" is idempotent under an unchanged state and is not idempotent when a different write lands in between. This retry happens seconds later on a fresh session, which is precisely when state has moved.So the rule that survives is narrower than the annotations suggest: retry needs proof that the request was not processed (a 404 on the session, or a transport closed before the request was written, which is the first bullet in the report), and annotations can only widen it for tools whose effects are recomputable.
The structural fix belongs at the request, not in each client. Both attempts carry the same
tool_use, so a stable idempotency key stamped by the client and passed through_metawould let the server dedupe the second call instead of re-running it. That scales to every other client, none of which will implement the same annotation policy. Without it, each client's retry rules are a private guess about someone else's side effects.Two smaller things from the log in the report:
Retrying tool ... after session recovery) is emitted for both the safe 404 case and the unsafe mid call close, so a post mortem cannot separate them from the log alone. One field on that line, distinguishingsession_404fromconnection_closed_midcall, makes the two classes greppable, which is what you need when the complaint arrives days later.For the regression, the case that matters is a server that closes after the handler completed and after a partial response was written, plus a proxy that closes while the server finishes normally. Both look identical from the client side, and both prove nothing about execution.