Skip to main content
Glama
krish-shahh

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.

![Without vs. with mcp-idempotent: a retried tools/call re-executes the tool directly, but waits for the original and returns its cached result through the proxy](./docs/compare.png)

```
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.

![MCP Idempotency Proxy architecture: client, proxy (derive key, check store, forward, cache), idempotency store, and the wrapped MCP server](./docs/proxy.png)

## 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.