Home Blog Contact
Home/Blog/How to Make MCP Tools Idempotent for Retrying…
ArticleLLM EngineeringMCPAI AgentsReliability

How to Make MCP Tools Idempotent for Retrying Agents

11 min readBy Miloš Mitrović

If an agent can retry a tool call, assume every write tool will eventually execute twice. The fix is an idempotency key derived from the tool name, the canonicalised arguments and a caller scope, stored alongside the result in a table you check before doing any work, so a second execution returns the first execution's response instead of performing the action again. The complication is that retries are generated independently at the model layer, the harness layer and the HTTP transport layer, so a defence that covers one of them will not cover the others. This walks through diagnosing which layer is duplicating, then the key derivation and storage that holds across reconnects.

Key takeaways

  • Duplicate side effects come from four independent sources: the model re-issuing a call, the harness retrying on exception, the MCP client resuming a broken stream, and HTTP infrastructure retrying a dropped connection.
  • The JSON-RPC request ID is unique only within a session, so it is unusable as a dedupe key the moment a client reconnects.
  • Derive the key from subject + tool + sha256(canonical args) with a short expiry window, and let clients override it with an explicit key.
  • Store the outcome, not only the key. A duplicate must return the original response verbatim, otherwise the model sees two different worlds.
  • Claim the key before doing the work, so a duplicate arriving mid-execution gets an in-progress answer rather than a silent second run.
  • Client-side timeouts on slow tools are the largest single generator of duplicate calls. Send progress notifications or move to a job-and-poll shape.

Where duplicate tool executions come from

Four layers can each independently cause a tool to run twice, and they leave different fingerprints. First, the model re-issues the call: it saw a timeout, it saw an error result it read as a failure, or the earlier result was dropped when the conversation was compacted (see agent context compaction for how easily a tool result disappears from the window). Second, the harness retries: most agent frameworks wrap tool invocation in a try block with a retry policy, and a tool that raised after committing its write gets called again. Third, the MCP client resumes a broken stream: Streamable HTTP allows a disconnected client to reconnect and replay from a message ID, and a client that instead re-POSTs the original request produces a second server-side execution (MCP transports specification, and the Last-Event-ID semantics in the HTML standard that resumption builds on). Fourth, infrastructure retries: proxies and HTTP client libraries retry on connection reset, and although POST is defined as non-idempotent (RFC 9110, idempotent methods), a reset before any response bytes arrive is a case many clients retry anyway.

Only the first two are visible from inside the agent framework. The last two happen below it, which is why a retry counter in your harness reads zero while your database shows two refunds.

How to tell a duplicate from a deliberate repeat

Log five fields per invocation and the layer identifies itself: session ID, JSON-RPC request ID, tool name, argument hash, and the ID of whatever downstream write happened. Then group by argument hash inside a ten minute window and look at what differs between the two rows.

PatternMost likely source
Same session ID, same request IDTransport or proxy retry below the client
Same session, different request IDs, milliseconds apart, no model output betweenHarness retry policy
Same session, different request IDs, seconds apart, assistant text between themModel re-issued the call after reading a failure
Different session IDsClient reconnected and replayed queued work

Emit these as span attributes rather than log lines so the grouping is a query and not a grep. The OpenTelemetry GenAI semantic conventions already define span names and attributes for tool execution, which is enough structure to run this analysis without inventing a schema (more on wiring that up in agent observability with OpenTelemetry).

A deliberate repeat looks different: the arguments differ in at least one field, or the calls are minutes apart with unrelated tool calls between them. That distinction is what your expiry window has to encode.

Why the JSON-RPC request ID is the wrong dedupe key

The request ID is scoped to a session, so it carries no meaning after a reconnect. The MCP base protocol requires only that a request ID has not been used previously by the same requestor within the same session, which means client number two, or the same client after a restart, will happily send you request ID 3 again for entirely different work. Deduping on it would both miss cross-session duplicates and collide unrelated calls.

The session ID is no better as a stability anchor. Servers that hold no per-session state, which is the shape most people end up with behind a load balancer, have nothing to key on at all (see where MCP server state lives without sessions, and serving MCP over Streamable HTTP for why multiple nodes make this concrete). The key has to be derivable from the content of the call plus the authenticated caller, and from nothing else.

How to derive a key that survives reconnects

Hash the canonicalised arguments, prefix with the authenticated subject and the tool name, and allow an explicit override. Canonicalisation matters more than the hash choice: sort object keys, use a fixed separator, normalise numeric formatting, and strip the idempotency parameter itself before hashing.

import hashlib
import json

def build_key(tool: str, args: dict, subject: str, explicit: str | None) -> str:
    if explicit:
        return f"{subject}:{tool}:x:{explicit}"
    payload = {k: v for k, v in args.items() if k != "idempotency_key"}
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
    return f"{subject}:{tool}:h:{digest}"

Two failure cases are worth naming. Arguments containing a client-generated timestamp or a nonce will hash differently on every retry, which defeats the whole mechanism, so either exclude those fields or round them. And argument-derived keys cannot distinguish a retry from a genuine second identical action, for example sending the same one-line message to the same channel twice on purpose. The window resolves it: a ten minute expiry catches every retry path (harness retries are immediate, model re-issues are seconds, reconnects are under a minute) while allowing an intentional repeat later. Where the caller can do better, let it: expose an optional idempotency_key string on write tools and document it in the tool description so a well-behaved client passes a stable UUID per intent.

How to store the key and the result

Claim the key before doing the work, and record the response when the work finishes, in one table with a unique constraint doing the arbitration. The claim has to be an atomic insert-or-read, not a select followed by an insert, because two concurrent duplicates will both pass a select.

create table tool_idempotency (
  key           text primary key,
  state         text not null check (state in ('in_progress','succeeded','failed')),
  response      jsonb,
  attempt_count int not null default 1,
  created_at    timestamptz not null default now(),
  expires_at    timestamptz not null
);

create index on tool_idempotency (expires_at);
insert into tool_idempotency (key, state, expires_at)
values ($1, 'in_progress', now() + interval '10 minutes')
on conflict (key) do update
  set attempt_count = tool_idempotency.attempt_count + 1
returning state, response, attempt_count, (xmax = 0) as inserted;

The xmax = 0 test tells you whether this statement inserted the row or collided with an existing one, which is the branch your tool logic hangs off. Run a sweeper that deletes rows past expires_at so a stale key never wins a conflict against fresh work.

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError

mcp = FastMCP("billing")

@mcp.tool()
async def issue_refund(order_id: str, amount_cents: int,
                       idempotency_key: str | None = None) -> dict:
    """Refund an order. Pass a stable idempotency_key to make retries safe."""
    key = build_key("issue_refund",
                    {"order_id": order_id, "amount_cents": amount_cents},
                    subject=current_subject(), explicit=idempotency_key)
    claim = await claim_key(key)

    if not claim.inserted:
        if claim.state == "succeeded":
            return {**claim.response, "duplicate": True}
        if claim.state == "in_progress":
            raise ToolError(
                "A refund for this order is already in flight. "
                "Do not retry; call get_refund_status with the order_id.")
        await reset_claim(key)  # previous attempt failed, take it over

    try:
        result = await gateway.refund(order_id, amount_cents, idempotency_key=key)
    except Exception:
        await finish_key(key, "failed", None)
        raise
    await finish_key(key, "succeeded", result)
    return result

Note that the same key is forwarded to the payment gateway. Doubling up like this is deliberate: your table protects against duplicate invocations of your tool, and the gateway's own key protects against the window between your claim and its commit.

What to return to the model on a duplicate

Return the original result verbatim, with a small marker, and never an error. An error result is the single most reliable way to trigger another retry, so answering a duplicate with "duplicate request rejected" produces exactly the loop you were trying to prevent. Anthropic's tool use documentation describes how a tool result marked as an error is fed back for the model to recover from, and recovery here means calling again.

The in-progress case is the one place an error is right, and its content has to be instructional: say the work is in flight, say explicitly not to retry, and name the tool that reports status. Models follow that reliably when the alternative action is spelled out and vaguely when it is not.

Keep the stored response small. Cap it at something like 64 KB and store a pointer for anything larger, because this table is read on the hot path of every write call and you do not want a megabyte of JSON in it.

Tools that cannot be made idempotent

Some actions have no natural key, and for those you pick one of three strategies rather than pretending the dedupe table solved it. The first is to push the key downstream when the provider supports one: Stripe accepts an Idempotency-Key header and replays the original response for 24 hours (Stripe idempotent requests), and any API with that behaviour lets you inherit correctness instead of approximating it.

The second is client-assigned resource IDs. If the tool creates a record and you generate the ID before the write, creation becomes an upsert, and the second attempt is a no-op by construction. This is the cleanest option when you control the downstream system.

The third is a search-then-create with a marker embedded in the payload, for appends into systems that support neither. Write a short token derived from the key into a field the target preserves (a tag, a custom property, a trailing HTML comment), and search for that token before creating. It is slower and it races under true concurrency, which the in-progress claim is there to cover.

For irreversible and expensive actions, add a human gate rather than relying on any of the above. Elicitation gives you a first-class way to ask before acting, and the broader question of what an agent is allowed to spend without confirmation is covered in agents that spend.

How to stop timeouts from manufacturing retries

Most duplicate executions I have traced started as a client-side timeout on a tool that was still working. The client gives up at 30 or 60 seconds, the model is told the call failed, and it calls again while the first invocation is quietly still running and about to commit. Two things fix this at the source.

Send progress notifications. The MCP lifecycle specification allows implementations to reset the timeout clock when a progress notification arrives for a long-running request, subject to a maximum, so a tool that reports every few seconds stops being cancelled at all. The mechanics are in streaming progress from a long-running MCP tool.

Better still, change the shape. Anything that routinely runs past about 30 seconds should return a job ID immediately and expose a separate status tool. The write commits once, inside a worker the transport cannot interrupt, and the retry-prone path becomes a read.

Trade-offs and what I would ship

Do not put this machinery on every tool. Classify tools into three buckets, and only the third pays for it: reads need nothing, naturally idempotent writes (set a field to a value, upsert by a stable ID) need nothing, and non-idempotent writes (charge, refund, send, create, append) need all of it. On a typical server that is four or five tools out of thirty, which keeps the surface small enough to test properly.

On storage, use whatever already holds your transactional state rather than adding Redis for this. The reason is not operational convenience but correctness: where the side effect is a write to your own database, the idempotency row and the side effect must commit in the same transaction, otherwise you get a claim with no action (permanently blocking a legitimate retry) or an action with no claim (duplicating on the next attempt). Redis cannot participate in that transaction. Where the side effect is external, the two-phase claim-then-finish above is the best available approximation, and its residual failure window is the reason you still forward a key to the provider.

On key derivation, explicit client keys are correct and argument hashes are a heuristic, but clients rarely send explicit keys today, so shipping hash-plus-window as the default and accepting an override is the pragmatic order. Pick the window from your slowest tool: it has to exceed the longest plausible retry gap (model re-issue after a timeout, so roughly two times your client timeout) and stay short enough that a user repeating an action deliberately is not silently ignored. Ten minutes fits most systems; a tool a human might legitimately fire twice in a minute needs an explicit key rather than a shorter window.

The cost is one round trip to Postgres per protected call, low single-digit milliseconds against an agent turn measured in seconds. The thing that actually costs you is discipline in the failure branch: if your tool can raise after the side effect committed and before finish_key runs, you have moved the bug rather than fixed it. Test that branch by killing the process between the two writes, because that is the sequence production will find.

Sources

M
Miloš Mitrović
Email Marketing for Ecommerce

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.