mcp-idempotent
A retried MCP tool call can re-execute a side effect — double-charge a card, file a duplicate ticket, send a message twice. mcp-idempotent sits transparently in front of any existing, unmodified MCP server and deduplicates tools/call requests, Stripe-style, so a retry returns the original result instead of re-running the tool.

Direct (no proxy): 5 identical charge_card calls -> 5 executions (80% duplicate rate)
Through mcp-idempotent: 5 identical charge_card calls -> 1 execution (0% duplicate rate)
Added latency (in-memory store): 0.07ms p50 / 0.02ms p99
Numbers above are from bench/results.md, produced by npm run bench against the mock server in this repo — not a synthetic claim.

Why this doesn't already exist
MCP's Tasks proposal (SEP-1686, finalized October 2025) does add an idempotency mechanism — but only for task creation: a client-generated task ID lets the server reject a duplicate with an error, so a retried task-augmented request can't spawn a second task. It says nothing about an ordinary tools/call — the common case, since most tool calls aren't tasks. The other piece of prior art in the spec, the idempotentHint tool annotation, is a static declaration ("this tool happens to be idempotent") — not an enforcement mechanism; nothing in the protocol checks it or acts on it. Existing OSS MCP gateways (IBM/mcp-context-forge, microsoft/mcp-gateway, aws/mcp-proxy-for-aws, and others) ship auth, rate limiting, and observability, but none ship idempotency-key deduplication of tool calls. mcp-idempotent fills that specific gap: a drop-in proxy for servers you don't control or don't want to modify.
How it works
mcp-idempotent is an MCP-transport-aware relay. It sits between the MCP host (Claude Desktop, your own client, etc.) and the real server, forwarding every message verbatim in both directions — except tools/call requests:
- It derives an idempotency key from the tool name, a hash of the canonicalized arguments, and a run-scoped identifier (by default, one per proxy process — i.e. one per client session).
- It atomically reserves that key in a store. If it wins the race, the call is forwarded to the real server as normal.
- If a second identical call arrives while the first is still in flight, or after it completed, the proxy returns the same result without touching the real tool again.
- A call that fails at the transport level (the server crashed, the connection dropped) is not cached — the key becomes reservable again, so a genuine retry actually retries.
You can also pass an explicit key yourself via params._meta["idempotency-key"] on the tool call, if your client already generates one.
Quickstart
npx mcp-idempotent -- node my-server.js
That's it — point whatever previously launched node my-server.js at npx mcp-idempotent -- node my-server.js instead. No changes to the server.
mcp-idempotent [options] -- <command> [args...]
Options:
--store <memory|redis> Idempotency store backend (default: memory)
--redis-url <url> Redis connection string (default: $REDIS_URL). Required with --store redis.
--ttl-ms <n> How long a completed call is remembered, in ms (default: 600000)
As a library
import { IdempotentProxy, MemoryStore } from "mcp-idempotent";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const proxy = new IdempotentProxy({
upstream: new StdioClientTransport({ command: "node", args: ["my-server.js"] }),
downstream: new StdioServerTransport(),
store: new MemoryStore(),
});
await proxy.start();
Redis-backed store (for sharing state across proxy instances):
import { RedisStore } from "mcp-idempotent/store/redis";
import { Redis } from "ioredis";
const store = new RedisStore(new Redis(process.env.REDIS_URL!));
Project layout
src/proxy.ts core proxy: intercepts tools/call, computes key, checks store
src/key.ts idempotency key derivation (run_id + tool + arg hash)
src/store/ storage adapters behind one IdempotencyStore interface
bin/cli.ts npx entrypoint, wraps a target MCP server process
demo/server.ts mock MCP tool server with a simulated dropped-response mode
demo/run.ts drives Claude (real tool use) against demo/server.ts, with and without the proxy
bench/run.ts reproducible latency + duplicate-rate benchmark
bench/results.md committed output of the last benchmark run
docs/compare.png without/with side-by-side diagram (used in this README)
docs/proxy.png architecture diagram (used in this README)
Commands
npm run build # tsup -> dist/
npm test # vitest
npm run lint # tsc --noEmit
npm run dev # run the proxy against the local demo server
npm run demo # Claude-driven retry demo (requires ANTHROPIC_API_KEY)
npm run bench # latency + duplicate-rate benchmark -> bench/results.md
Testing strategy
- Unit tests for key derivation and each store adapter (
tests/key.test.ts,tests/store-*.test.ts) - An integration test spins up a real MCP
Clientagainst a realIdempotentProxyin front of a mockMcpServer(in-memory transports), simulates a dropped response by racing a retry against an in-flight call, and asserts the tool handler fires exactly once (tests/proxy.integration.test.ts) bench/run.tsis separate from the test suite — informational, not a CI gate
Design notes
The store interface is the only way a backend touches state:
export interface IdempotencyStore { get(key: string): Promise<CachedResult | null>; reserve(key: string): Promise<boolean>; // atomic claim, false if already claimed complete(key: string, result: CachedResult): Promise<void>; }Only successful completions are cached. A JSON-RPC-level error (the underlying server crashing, the connection dropping) is recorded as
failed, which makes the key reservable again — so a real retry after a real failure actually re-executes, matching Stripe's idempotency-key semantics.No telemetry or phone-home in the default build.
Write-up
mcp tool calls have no retry story, so i built one — the longer version of this README, with the design decisions and the actual benchmark run.