[BUG] MCP tools/call is re-sent after '-32000 Connection closed' mid-call, so a non-idempotent tool runs twice

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

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] 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/call automatically 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: true or idempotentHint: 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

  1. 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).
  2. 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.
  3. Claude Code logs "MCP session expired during tool call (connection closed)" and "Retrying tool ... after session recovery", then calls the tool again.
  4. 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.

View original on GitHub ↗