mcp tool calls have no retry story, so i built one
a retried mcp tool call can double-charge a card or file a duplicate ticket, and nothing in the protocol or the existing gateways stops that — so i built a drop-in proxy that does.

here's a shape of bug that's going to get more common as more of the world runs through agents calling tools: a client calls charge_card, the card actually gets charged, and then the response gets lost on the way back — dropped connection, timeout, whatever. the client has no idea if the call succeeded. so it does the reasonable thing and retries with the exact same arguments. the tool runs again. the card gets charged twice.
that's not a hypothetical. it's just what happens, today, to any mcp server that doesn't have hand-rolled deduplication logic sitting in front of it. i went looking for whether anyone had actually solved this generically and came up empty, so i built mcp-idempotent — a proxy that sits in front of any existing mcp server and makes retries safe, stripe-style, with zero changes to the server.
why nobody's built this yet
first thing i did was check whether this was already solved, because building a proxy for a problem someone already fixed is a bad use of a weekend.
mcp does have an idempotency mechanism, in sep-1686, the tasks proposal that went final in october 2025. but it's scoped narrowly: a client generates its own task id, and if it retries, the server recognizes the duplicate id and returns an error. that's real and it works, but it only covers task-augmented requests. an ordinary tools/call — which is most tool calls, since most tools aren't modeled as tasks — gets none of that. there's also an idempotentHint annotation a tool can set, but it's just a label the tool author attaches saying "i happen to be safe to retry." nothing in the protocol checks it, enforces it, or does anything with it.
so i looked at the existing mcp gateways next — ibm/mcp-context-forge, microsoft/mcp-gateway, aws/mcp-proxy-for-aws. all real, all shipping — auth, rate limiting, observability, one of them even circuit breakers. none of them dedupe tool calls. the gap was genuinely open: an mcp-transport-aware proxy that adds idempotency-key deduplication in front of a server you don't control and don't want to modify.
how it actually works
the core idea is one you've seen if you've ever used stripe's api: the caller (or in this case, the proxy on the caller's behalf) derives a key, and the server treats two calls with the same key as one logical operation. the proxy computes that key from the tool name, a hash of the canonicalized arguments, and an identifier scoped to the current session — so identical calls in unrelated sessions don't collide with each other by accident.
the interesting part isn't the hashing, it's what happens when a duplicate shows up while the original call is still in flight. the naive version rejects it or errors. what actually needs to happen: the second caller has to wait for the first one to finish and get the exact same answer, because the first call might still be genuinely processing — the caller only thinks the response was lost, it doesn't actually know that yet. so the store has three states for a key, not two: pending, complete, and failed. a duplicate that lands while its key is pending polls until it resolves, instead of bouncing.
that third state, failed, is the one i almost got wrong. if the underlying server actually crashes or the connection drops — a real transport failure, not a dropped response — the call must not be cached as if it succeeded. otherwise a legitimate retry after a real failure just replays a non-answer forever. so only genuine completions get cached; a transport-level failure marks the key failed, which makes it reservable again, so the next attempt actually attempts.
export type CachedResult =
| { status: "pending"; timestamp: number }
| { status: "complete"; result: unknown; timestamp: number }
| { status: "failed"; timestamp: number };
does it actually work
ran the actual benchmark rather than estimate one. five identical charge_card calls, same arguments, back to back:
| executions | duplicate rate | |
|---|---|---|
| direct, no proxy | 5 | 80% |
| through mcp-idempotent | 1 | 0% |
and the overhead of putting a proxy in the path at all, measured over 200 calls with the in-memory store:
| p50 | p99 | |
|---|---|---|
| added latency | 0.07ms | 0.02ms |
sub-tenth-of-a-millisecond overhead for turning an 80% duplicate-execution rate into zero. i also ran the whole thing end to end with claude actually doing the tool-calling through the anthropic api, simulating a dropped response on purpose: two executions for one logical charge without the proxy, one with it. same input, same simulated drop, same everything else — the only variable was whether the proxy was in the path.
it's mit-licensed, ships an in-memory store by default with a redis adapter if you need state shared across processes, and the whole install is npx mcp-idempotent -- node your-server.js in front of whatever you're already running. no server-side changes, because the whole point was that you shouldn't need any.