mcp-idempotent
by krish-shahh
README.md
# mcp-idempotent
A retried [MCP](https://modelcontextprotocol.io) 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`](./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](https://modelcontextprotocol.io/seps/1686-tasks) (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:
1. 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).
2. It atomically reserves that key in a store. If it wins the race, the call is forwarded to the real server as normal.
3. 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.
4. 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
```bash
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.
```bash
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
```ts
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):
```ts
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
```bash
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 `Client` against a real `IdempotentProxy` in front of a mock `McpServer` (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.ts` is separate from the test suite — informational, not a CI gate
## Design notes
- The store interface is the only way a backend touches state:
```ts
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](http://krish-shah.vercel.app/blog/2026-08-06-mcp-idempotent) — the longer version of this README, with the design decisions and the actual benchmark run.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues