Content
# mcp-retry
A resilience proxy for [MCP](https://modelcontextprotocol.io) (Model Context Protocol) servers.
It sits between an MCP client (Claude Code, Claude Desktop, or any other MCP
client) and an existing MCP server, and transparently adds the things a flaky
network call needs but the MCP spec doesn't yet standardize:
- **Retry with exponential backoff** on transient errors.
- **Idempotency-key-based dedup** — a retried call (same tool + arguments, or
an explicit idempotency key) that already succeeded, or is still in flight,
is never re-executed; the original result is returned instead.
- **TTL result caching** for read-only/idempotent tools, configurable
globally or per tool.
- **Circuit breaker** — after N consecutive failures against a given
downstream server, fail fast for a cooldown window instead of hammering a
server that's already down.
- **`mcp-retry check`** — a health-check mode that pings every configured
downstream server and reports up/down status, designed to run as a Claude
Code `SessionStart` hook.
It works with **any** MCP server: mcp-retry never inspects or hardcodes tool
schemas. You give it the same launch command you'd otherwise put directly in
your MCP client's config, and it relays the JSON-RPC protocol through
unchanged except for the resilience behavior above.
## Why this exists
MCP's own roadmap discussion has repeatedly flagged retry, idempotency, and
result-expiry semantics as open problems: the spec defines the transport and
message shapes, but says nothing about what a client or intermediary should
do when a tool call times out, errors intermittently, or succeeds but the
response is lost. Every MCP client and server author is left to invent their
own answer, or more commonly, to not have one — a `tools/call` that times out
today is usually just an error shown to the user, with no safe way to know
whether the underlying operation actually happened before deciding whether
retrying it is safe.
Existing MCP-adjacent tooling (proxy/gateway projects like Shield, agentfw,
mcp-scan, and similar) focuses on *security* — sandboxing, permission
scoping, prompt-injection scanning. None of them address the much more
mundane problem of "my MCP server times out or errors intermittently and I'd
like calls to survive that automatically." mcp-retry is a small, focused tool
for exactly that gap: it does not do security policy, sandboxing, or content
inspection — only transport-level resilience.
## Install
```bash
git clone https://github.com/bentabol/mcp-retry.git
cd mcp-retry
pip install .
```
This installs the `mcp-retry` console command (via `pip install -e .` for an
editable/development install instead). Requires Python 3.10+. The only
runtime dependency is `PyYAML`.
## Configure
Write a config file describing the downstream server(s) you want to wrap.
Each entry's `command`/`args`/`env`/`cwd` are exactly what you'd otherwise
put directly in your MCP client's own server config. YAML or JSON both work
(picked from the file extension).
```yaml
# mcp-retry.yaml
servers:
filesystem:
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allow"]
call_timeout: 20 # seconds to wait for one downstream response
retry:
max_retries: 3 # additional attempts after the first
base_delay: 0.5 # seconds; doubles each retry
max_delay: 20
jitter: 0.2 # +/- 20% randomization to avoid thundering herds
cache:
default_ttl: 0 # 0 = caching off unless a tool overrides it
tools:
list_directory: 30 # seconds; read-only tools are safe to cache
read_file: 15
circuit_breaker:
failure_threshold: 5 # consecutive failures before tripping open
cooldown_seconds: 30 # how long to fail fast before a trial call
idempotency_ttl: 300 # seconds a successful result is kept so an
# exact retry (same args, or explicit key)
# returns it instead of re-executing
everything:
command: npx
args: ["-y", "@modelcontextprotocol/server-everything"]
```
A fuller, commented example lives at
[`examples/config.example.yaml`](examples/config.example.yaml).
### Sending an explicit idempotency key
If your MCP client wants to mark a specific tool call as safe to replay
(e.g. "charge this card", "create this record"), it can supply an
idempotency key either of two ways — mcp-retry strips the second form before
forwarding the call downstream, since the wrapped tool never asked for it:
- `_meta.idempotencyKey` on the `tools/call` request params (MCP's own
`_meta` convention for request-level metadata), or
- an `_idempotency_key` entry inside `arguments`.
Two calls that share a key are treated as the same operation regardless of
their other arguments, for `idempotency_ttl` seconds after the first one
completes.
## Use
### As a drop-in wrapper in your MCP client config
Point your client at `mcp-retry` instead of the real server, and pass it the
config file plus which server entry to use:
```json
{
"mcpServers": {
"filesystem": {
"command": "mcp-retry",
"args": ["run", "--config", "/absolute/path/to/mcp-retry.yaml", "--server", "filesystem"]
}
}
}
```
mcp-retry launches the real server as a subprocess and proxies stdio
transparently: `initialize`, `tools/list`, `resources/*`, `prompts/*`, and
server-initiated requests (e.g. sampling) all pass through untouched. Only
`tools/call` gets the retry/cache/dedup/circuit-breaker treatment, since
that's the only message type where re-executing something has real-world
consequences.
If the config defines only one server, `--server` can be omitted.
### Health-check mode
```bash
mcp-retry check --config /absolute/path/to/mcp-retry.yaml
```
```
[UP ] filesystem 180.4ms ok
[DOWN] everything - no response within 10.0s
```
Pings every configured server with a bare `initialize` request and reports
up/down status and latency. Exits `0` if every server responded, `1`
otherwise. Add `--json` for machine-readable output and `--timeout <seconds>`
to change the per-server wait (default 10s).
### Wiring `check` as a Claude Code `SessionStart` hook
This is the intended way to use `check` day-to-day: run it once when a
Claude Code session starts, so a dead or misconfigured downstream MCP server
is surfaced as context up front instead of discovered mid-task as a
mysterious tool failure.
Add to `.claude/settings.json` (project-level) or `~/.claude/settings.json`
(user-level):
```json
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "mcp-retry check --config /absolute/path/to/mcp-retry.yaml ; exit 0",
"timeout": 15
}
]
}
]
}
}
```
Notes on why it's written that way:
- `matcher: "startup"` runs the hook on a fresh session only; use `"resume"`
or omit `matcher` entirely to also run it on resumed/cleared/compacted
sessions.
- **`; exit 0` is deliberate.** Claude Code only injects a `SessionStart`
hook's stdout as context into the session when the hook itself exits `0`;
on a non-zero exit only the first line of *stderr* is surfaced (as a small
hook-error notice), not the full up/down table. Since `mcp-retry check`
exits `1` when any server is down — the exact case you actually want to
see — forcing the hook's own exit code to `0` with `; exit 0` (or
`|| true`) ensures the full table always reaches the session as context,
whether every server is healthy or one is down. `SessionStart` hooks never
block session startup regardless of exit code, so this doesn't hide
failures — it just makes sure you actually see them.
- Use an absolute path for `--config` (or `${CLAUDE_PROJECT_DIR}` for a
project-relative one) — hooks don't run with the same working-directory
assumptions as an interactive shell.
- `timeout` is in seconds and applies to the hook process as a whole; keep it
a little above `check`'s own `--timeout` (default 10s) so a slow server
doesn't get killed by the hook harness before `check` reports it as down
itself.
## How it decides what's retryable
Only failures classified as *transient* are retried:
- Any transport-level failure (timeout, broken pipe, subprocess exit) is
always transient.
- A JSON-RPC error response is transient only if its `code` is one of the
generic "server error" range (`-32000`..`-32003`) or `-32603` (internal
error). Anything else — invalid params (`-32602`), a tool's own reported
application error, etc. — is assumed to be a real, repeatable failure and
is returned to the client immediately, unretried.
This is deliberately conservative: retrying a call that fails because of bad
input would just waste time reproducing the same failure.
## Development
```bash
pip install -e ".[dev]"
python -m pytest -v
```
The test suite spins up a small configurable mock MCP server
(`tests/mock_server.py`, a real subprocess speaking the same line-delimited
JSON-RPC stdio protocol) behind a real `mcp_retry.proxy.Proxy`, and drives
scenarios like "fail twice then succeed", "always fail", and "respond
slowly" to exercise retry recovery, circuit-breaker trip/half-open/recover,
TTL caching, and idempotency-key dedup against real async I/O rather than
mocks of mcp-retry's own internals. `tests/test_cli_end_to_end.py` goes one
level further and drives the actual `python -m mcp_retry run` subprocess
over its real stdin/stdout.
## What this is not
- Not a security tool. It does no sandboxing, permission scoping, or content
inspection — pair it with something like mcp-scan if you need that.
- Not a general MCP gateway/multiplexer. One `mcp-retry run` process wraps
one downstream server; if you want to fan out to several, run one
`mcp-retry` instance per server (as in the config example above) and point
your client at each.
- Retries and caching apply only to `tools/call`. Everything else passes
through unmodified.
## License
MIT — see [LICENSE](LICENSE).