Skip to content

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

Open

Description

@Exploitacious

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

Activity

  1. 0xdhx commented on Oct 22, 2026

    @0xdhx

    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) and nudge (idempotentHint: true). It logs every tools/call arrival, and on the first call to each tool it ends the POST's SSE response without a result. One claude -p turn 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 by tool_use id: a second arrival under one tool_use id is a client re-send. The decisive cells were re-run on the unmodified release binaries.

    tools/call arrivals at the server for one model tool_use:

    tool 2.1.304 2.1.307
    poke (no hints) 2 (re-sent) 1
    peek (readOnlyHint: true) 2 2
    nudge (idempotentHint: true) 2 2

    2.1.304, poke: the same sequence as in this issue.

    MCP server "fake": MCP session expired during tool call (connection closed), clearing connection cache for re-initialization
    MCP server "fake": Retrying tool 'poke' after session recovery
    MCP server "fake": Tool 'poke' completed successfully in 3ms
    

    2.1.307, poke: the same "session expired … (connection closed)" line, no retry, and the model receives:

    MCP server "fake" connection closed before the tool call was answered. The tool may have run: check whether it did before running it again.
    

    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:

  2. Ben-Home commented on Oct 27, 2026

    @Ben-Home

    The annotation gate removes the reported failure but not the general shape, and that is worth recording before this closes.

    readOnlyHint and idempotentHint are server authored claims. The client cannot verify them, which is tolerable for readOnlyHint (a server that lies about read only is already free to do anything) but weaker for idempotentHint, 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 _meta would 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:

    • The retry line (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, distinguishing session_404 from connection_closed_midcall, makes the two classes greppable, which is what you need when the complaint arrives days later.
    • When the client does not retry, the error should carry the "may have executed" fact as a structured field rather than prose only. A consuming harness can then choose to verify state instead of retrying on its own, which is the behaviour an agent loop actually needs here.

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:mcpbugSomething isn't workinghas reproHas detailed reproduction steps

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions