Retry Safety in MCP Tool Calls
A tool call that fails on the client can still have completed on the server. If the caller retries, the tool runs twice. This post documents four reproductions of that, the relevant spec text, and the current state of idempotency support in the reference implementations.
Summary
| # | Scenario | Transport | Retry issued by | Calls | Effects |
|---|---|---|---|---|---|
| 1 | Client timeout, then cancel | stdio | test harness | 1 | 2 |
| 2 | Server SIGKILL, restart | stdio | test harness | 1 | 2 |
| 3 | Client timeout, then cancel | Streamable HTTP | test harness | 1 | 2 |
| 4 | ConnectionError after tool returns |
stdio | LangGraph, default policy | 1 | 3 |
In every case the tool declares idempotentHint: false and the client receives that annotation.
Environment
Pinned, because all of this moves weekly.
| Component | Version |
|---|---|
@modelcontextprotocol/sdk (TypeScript) |
1.30.0 |
mcp (Python) |
1.29.1 |
langgraph |
1.2.11 |
langchain-mcp-adapters |
0.3.2 |
langchain-core |
1.6.1 |
@cloudflare/think |
0.17.0 |
| Negotiated protocol version | 2025-11-25 |
| Node | 26.7.0 |
The TypeScript SDK’s LATEST_PROTOCOL_VERSION is 2025-11-25 and SUPPORTED_PROTOCOL_VERSIONS ends there, so the reference client cannot negotiate 2026-07-28. Reproductions ran on 2025-11-25.
The test server exposes one tool, charge, which appends a line to a ledger file and then sleeps. The write happens before the sleep, so the effect commits early in the call and any later failure leaves it in place.
What the 2026-07-28 revision changed
Two sets of changes in the current revision bear on this.
Retry appears in two features as ordinary control flow. Multi Round-Trip Requests resolve when the client supplies inputResponses “on a retry of the original request.” The elicitation completion notification was removed because “the client learns the outcome of an out-of-band interaction by retrying the original request.”
Four mechanisms that carried state across a connection were removed:
| Removed | Change |
|---|---|
Protocol sessions, Mcp-Session-Id |
Major change 1 |
SSE resumability, Last-Event-ID, event IDs |
Major change 9 |
initialize / notifications/initialized |
Major change 2 |
ping, logging/setLevel |
Major change 5 |
Major change 9 specifies the replacement behavior: “A broken response stream loses the in-flight request; clients MUST re-issue it as a new request with a new request ID.”
The revision reserved six _meta keys (clientCapabilities, clientInfo, logLevel, oauth, protocolVersion, serverInfo). None is a deduplication or idempotency key. The strings exactly-once, at-least-once, and deduplicate do not appear in the specification.
Scenario 1: timeout and cancellation
One second client timeout against a three second tool, followed by a retry.
[client] attempt 1 {"timeoutMs":1000}
[server] SIDE EFFECT COMMITTED {"reqId":"2","amount":100}
[client] attempt 1 FAILED {"code":-32001,"message":"Request timed out"}
[client] retrying
[server] <<< INBOUND notifications/cancelled {"requestId":2}
[server] SIDE EFFECT COMMITTED {"reqId":"3","amount":100}
[server] handler finishing {"reqId":"2","aborted":true}
[client] RESULT {"logicalInvocations":1,"actualCharges":2}
Three things in the ordering are worth noting.
notifications/cancelled reaches the server after the client has returned its error to the caller. Any retry logic above the SDK runs before the server is told anything.
The server handler for request 2 observed aborted: true and ran to completion. The write had already happened.
The client received no result for request 2. The spec directs servers not to respond to a cancelled request, so the caller’s information about the first attempt is that it did not return, which does not distinguish “did not happen” from “happened and the response was discarded.”
Scenario 2: process restart
The stdio transport section specifies the recovery procedure:
If the server process exits unexpectedly, the client SHOULD restart it. Because the protocol is stateless, any in-flight requests are simply lost and the client can retry them against the fresh process.
Running that as written, with a SIGKILL after the write commits:
[server] SIDE EFFECT COMMITTED {"reqId":"1","amount":100}
[client] SIGKILL the server process
[client] attempt 1 FAILED {"code":-32000,"message":"Connection closed"}
[client] restarting per spec guidance and retrying
[server] SIDE EFFECT COMMITTED {"reqId":"1","amount":100}
[client] RESULT {"logicalInvocations":1,"actualCharges":2}
The stated justification is that the protocol is stateless. Protocol statelessness describes the connection, not the server-side effect, and does not establish that a request is safe to repeat.
Both ledger entries carry reqId: 1. A restarted process begins its request counter again, so request IDs are not unique across a restart.
Scenario 3: Streamable HTTP
Same timeout and retry as scenario 1, over StreamableHTTPServerTransport and StreamableHTTPClientTransport. Two effects for one invocation, with distinct request IDs 1 and 2. Transport choice did not change the outcome.
Scenario 4: LangGraph’s default retry policy
Scenarios 1 through 3 use a retry written by the harness. This one uses the framework’s.
A LangGraph node calls charge through langchain-mcp-adapters and then raises ConnectionError, which is what a dropped MCP transport surfaces as. No LLM is involved, so the run is deterministic. The node uses the default RetryPolicy with no retry_on override.
[client] tool 'charge' metadata: {'title': None, 'readOnlyHint': None,
'destructiveHint': True, 'idempotentHint': False, 'openWorldHint': None}
[client] node attempt 1: calling charge -> 'charged 100.0'
[client] node attempt 2: calling charge -> 'charged 100.0'
[client] node attempt 3: calling charge -> 'charged 100.0'
[client] RESULT {"logicalInvocations":1,"nodeAttempts":3,"actualCharges":3}
Two properties of the default policy produce this.
langgraph/_internal/_retry.py checks isinstance(exc, ConnectionError) first and returns True. It then returns False for ValueError, TypeError, RuntimeError, ArithmeticError, LookupError, NameError and others, on the basis that those indicate program errors rather than transient ones. The classification is deliberate, and transport errors are the category where completion status is unknown.
Retry granularity is the node. The tool call sits inside the node, so re-running the node re-runs the call. This holds generally: checkpoint boundaries in these frameworks are coarser than a single tool call.
idempotentHint consumers
MCP has carried idempotentHint on tool annotations since revision 2025-03-26. Searching both reference stacks for code that reads it:
TypeScript, @modelcontextprotocol/sdk 1.30.0. One runtime occurrence, a Zod schema field in dist/esm/types.js. All other occurrences are .d.ts declarations, which are erased before execution.
Python, across langgraph 1.2.11, langchain-core 1.6.1, langchain-mcp-adapters 0.3.2, mcp 1.29.1. One occurrence, a field declaration at mcp/types.py:1276.
No consumer in either stack. The annotation is transmitted, received, and exposed to the agent layer in tool.metadata, as scenario 4 shows.
Client-side idempotency: @cloudflare/think
One implementation carries idempotency machinery. @cloudflare/think 0.17.0 has idempotencyKey across 15 files, an ActionPendingError type in 4, and documentation stating “Delivery is at-least-once; use idempotencyKey or occurrenceKey for your own durable idempotency.” It enumerates its residual at-least-once cases and which it accepts.
Searching its runtime files only, excluding .d.ts:
- 5 runtime files call
callTool - 8 runtime files reference
idempotencyKey - 0 runtime files contain both
The single co-occurrence in the dependency tree is a type declaration in agents/dist/agent-routing-*.d.ts. The idempotency keys are used by its own scheduling layer and do not reach tools/call, which reserves no field to carry one.
Scope
Not covered here:
- Protocol revision 2026-07-28. The reference client cannot negotiate it.
- Network partitions and clock skew. Both are untested.
- Frameworks other than LangGraph.
- Server implementations that maintain their own deduplication outside the protocol.
- Whether any production deployment has hit this. These are constructed reproductions.
Prior work
Sajjad Khan’s “Resume Means Resume” (August 2026) covers the persistence-layer half in more depth than this post: a six-property conformance contract checked in TLA+ and TLAPS, a 39-cell fault matrix, and measurements across five deployed frameworks under real SIGKILL. It measures LangGraph 1.2.9 as “exactly-once across interrupts, at-least-once across crashes” and reports that no two frameworks share a conformance profile. It does not cover MCP, partitions, or clock skew.
Related proposals
SEP-3182 proposed an optional idempotencyKey field on tools/call, letting a server recognize a retry and return the original result rather than re-executing. It was closed on 2026-08-23 with the comment “AI-generated PR with no disclosure or context.” The author asked what additional context was needed. The proposal’s technical content was not discussed in the thread.
Reproducing
No API keys and no model calls.
npm i @modelcontextprotocol/sdk@1.30.0 zod@3 tsx@4
LEDGER=$PWD/ledger.jsonl DELAY_MS=3000 TIMEOUT_MS=1000 node --import tsx client.ts
LEDGER=$PWD/ledger2.jsonl node --import tsx client-restart.ts
uv pip install langgraph langchain-mcp-adapters mcp
LEDGER=$PWD/ledger.jsonl python graph.py
One note for anyone extending this. An early version of scenario 2 spawned the server as npx tsx server.ts, which makes the transport’s child process npx and the server a grandchild. The SIGKILL hit npx, the server survived, attempt 1 succeeded, and the harness retried a successful call. It reported two effects and proved nothing. Spawn node --import tsx server.ts so the child is the server, and assert that attempt 1 fails with -32000 before counting anything.
What would resolve it
A dedup key on tools/call, which is what SEP-3182 proposed. Alternatively, a way for a client to ask a server what became of a request whose response it lost.
Neither exists today. Until one does, an application that needs a tool call to happen once has to carry its own key in the tool arguments and deduplicate server-side, because the protocol will not carry it.
Resources:
- Reproduction harness - four scenarios, pinned versions
- MCP 2026-07-28 changelog
- SEP-3182: Request Idempotency
- Resume Means Resume - Khan, conformance contract for workflow persistence