krish-shahh

mcp-idempotent

Community krish-shahh
Updated

A transparent proxy that gives any MCP server Stripe-style idempotency — retried tool calls return the cached result instead of running twice.

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.

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

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.

MCP Idempotency Proxy architecture: client, proxy (derive key, check store, forward, cache), idempotency store, and the wrapped MCP server

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:

  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

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 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:

    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.

MCP Server · Populars

MCP Server · New

    tenequm

    pond

    Lossless storage and search for AI agent sessions, across every agentic client.

    Community tenequm
    lineai-intelligence

    lineai-mcp-server

    An MCP Server to utilize Lineai's rich software dependency data in your AI programming assistant.

    mutonby

    OpenShorts.app

    Open source AI clip generator: turns long videos into viral 9:16 shorts with AI moment detection, face tracking, subtitles and dubbing. Self-host free with Docker (MIT), or use the cloud with GPU speed from $12/mo. MCP server and API for AI agents.

    Community mutonby
    legendaryvibecoder

    Gigabrain

    Local-first memory layer for OpenClaw, Codex App, and Codex CLI: capture, recall, dedupe, and native sync.

    Community legendaryvibecoder
    sysevol-ai

    Searchable codebase wikis and context for coding agents

    A multi-view data system for serving repository context to coding agents.

    Community sysevol-ai