Record, replay, and verify Model Context Protocol interactions for deterministic testing. MCP servers break silently. Tool schemas change, prompts drift, responses shift. Without wire-level regression tests, you find out from your users. mcp-recorder captures the full protocol exchange into a cassette file and lets you test from both sides. Try it right now — a scenarios.yml and a public demo serv
Add this skill
npx mdskills install devhelmhq/mcp-recorderProfessional testing framework for MCP servers with record/replay/verify modes and excellent documentation
1<p align="center">2 <img src="hero.gif" alt="mcp-recorder demo" width="720" />3</p>45# mcp-recorder — VCR.py for MCP servers67Record, replay, and verify Model Context Protocol interactions for deterministic testing.89[](https://pypi.org/project/mcp-recorder/)10[](https://python.org)11[](LICENSE)12[](https://github.com/devhelmhq/mcp-recorder/actions)1314MCP servers break silently. Tool schemas change, prompts drift, responses shift. Without wire-level regression tests, you find out from your users. mcp-recorder captures the full protocol exchange into a cassette file and lets you test from both sides.1516## Record. Replay. Verify.1718Try it right now — a [`scenarios.yml`](scenarios.yml) and a public demo server at `https://mcp.devhelm.io` are included so you can run this without any setup:1920```bash21pip install mcp-recorder2223# 1. Record cassettes from a scenarios file (zero code)24mcp-recorder record-scenarios scenarios.yml2526# 2. Inspect what was captured27mcp-recorder inspect cassettes/demo_walkthrough.json2829# 3. Verify your server hasn't regressed — compare responses to the recording30mcp-recorder verify --cassette cassettes/demo_walkthrough.json --target https://mcp.devhelm.io3132# 4. Replay as a mock server — test your client without the real server33# (starts a local server on port 5555, point your MCP client at it)34mcp-recorder replay --cassette cassettes/demo_walkthrough.json3536# Works with stdio servers too — no HTTP wrapper needed37mcp-recorder verify --cassette cassettes/golden.json \38 --target-stdio "node dist/index.js"39```4041One cassette. Three modes. HTTP and stdio transports. Full coverage for both client and server testing.4243## Contents4445- [Install](#install)46- [How It Works](#how-it-works)47- [Scenarios](#scenarios)48- [CLI Usage](#cli-usage)49- [pytest Integration](#pytest-integration)50- [Python API](#python-api)51- [Configuration](#configuration)52- [Cassette Format](#cassette-format)53- [CLI Reference](#cli-reference)54- [CI Integration](#ci-integration)55- [Roadmap](#roadmap)56- [Contributing](#contributing)5758## Install5960```bash61pip install mcp-recorder62```6364Or with [uv](https://docs.astral.sh/uv/):6566```bash67uv add mcp-recorder68```6970## How It Works7172mcp-recorder captures the full MCP exchange into a cassette file. It supports both HTTP (Streamable HTTP / SSE) and stdio (subprocess) transports — the transport is an implementation detail, the cassette format is the same. That single recording unlocks two testing directions:7374```75Record: Client -> mcp-recorder (proxy) -> Real Server -> cassette.json76 (HTTP or stdio subprocess)7778Replay: Client -> mcp-recorder (mock) -> cassette.json (test your client)79Verify: mcp-recorder (client mock) -> Real Server (test your server)80```8182**Replay** serves recorded responses back to your client. No real server, no credentials, no network.8384**Verify** sends recorded requests to your (updated) server and compares the actual responses to the golden recording. Catches regressions after changing tools, schemas, or prompts.8586## Scenarios8788Define what to test in a YAML file. No Python scripts, no boilerplate — works with MCP servers written in any language.8990```yaml91schema_version: "1.0"9293target: http://localhost:30009495redact:96 server_url: true97 env:98 - API_KEY99 patterns:100 - "sk-[a-zA-Z0-9]+"101102scenarios:103 tools_and_schemas:104 description: "Discover tools and call search"105 actions:106 - list_tools107 - call_tool:108 name: search109 arguments:110 query: "test"111112 error_handling:113 description: "Invalid inputs return proper errors"114 actions:115 - call_tool:116 name: search117 arguments: {}118```119120For stdio MCP servers, use a target object instead of a URL:121122```yaml123target:124 command: "node"125 args: ["dist/index.js"]126 env:127 API_KEY: "test-key"128 cwd: "./server"129```130131| Target field | Required | Description |132|---|---|---|133| `command` | yes | Executable to spawn |134| `args` | no | List of command-line arguments |135| `env` | no | Extra environment variables (merged with current env) |136| `cwd` | no | Working directory for the subprocess |137138### Environment Variables139140String values in `scenarios.yml` support `${VAR}` interpolation — the variable is resolved from the current environment at load time. Use `${VAR:-default}` to provide a fallback when the variable is not set. If a referenced variable is missing and no default is provided, loading fails with a clear error.141142```yaml143schema_version: "1.0"144145target:146 command: "node"147 args: ["dist/index.js"]148 env:149 API_KEY: "${API_KEY}"150 REGION: "${AWS_REGION:-us-east-1}"151152redact:153 env:154 - API_KEY155156scenarios:157 authenticated_search:158 description: "Search with a real API key"159 actions:160 - list_tools161 - call_tool:162 name: search163 arguments:164 query: "test"165```166167This works naturally with CI systems. In GitHub Actions, expose repository secrets as environment variables and `scenarios.yml` picks them up:168169```yaml170# .github/workflows/mcp-test.yml171jobs:172 test:173 runs-on: ubuntu-latest174 env:175 API_KEY: ${{ secrets.API_KEY }}176 steps:177 - uses: actions/checkout@v4178 - run: pip install mcp-recorder179 - run: mcp-recorder record-scenarios scenarios.yml -o cassettes/180```181182Interpolation applies to all string values: `target` URLs, `target.env` values, tool arguments, resource URIs, etc. Non-string values (numbers, booleans) are left unchanged. Dictionary keys are not expanded.183184Record all scenarios at once, or pick one:185186```bash187mcp-recorder record-scenarios scenarios.yml188mcp-recorder record-scenarios scenarios.yml --scenario tools_and_schemas189```190191Each scenario key becomes the cassette filename (`tools_and_schemas` -> `tools_and_schemas.json`). Protocol handshake (`initialize` + `notifications/initialized`) is handled automatically.192193Supported actions:194195| Action | Description |196|---|---|197| `list_tools` | Call `tools/list` |198| `call_tool` | Call `tools/call` with `name` and `arguments` |199| `list_prompts` | Call `prompts/list` |200| `get_prompt` | Call `prompts/get` with `name` and optional `arguments` |201| `list_resources` | Call `resources/list` |202| `read_resource` | Call `resources/read` with `uri` |203204## CLI Usage205206### Interactive Recording207208Start the proxy pointing at your MCP server:209210```bash211# HTTP target212mcp-recorder record \213 --target http://localhost:8000 \214 --port 5555 \215 --output golden.json216217# stdio target — spawns the server as a subprocess218mcp-recorder record \219 --target-stdio "node dist/index.js" \220 --target-env API_KEY=test-key \221 --output golden.json222```223224Point your MCP client at `http://localhost:5555` and interact normally. Press `Ctrl+C` when done — the cassette is saved.225226Works with remote servers too:227228```bash229mcp-recorder record \230 --target https://mcp.example.com/v1/mcp \231 --redact-env API_KEY \232 --output golden.json233```234235For automated recording, see [Scenarios](#scenarios).236237### Verify238239After making changes to your server, verify nothing broke:240241```bash242# HTTP target243mcp-recorder verify --cassette golden.json --target http://localhost:8000244245# stdio target246mcp-recorder verify --cassette golden.json \247 --target-stdio "node dist/index.js" \248 --target-env API_KEY=test-key249```250251```252Verifying golden.json against http://localhost:8000253254 1. initialize [PASS]255 2. tools/list [PASS]256 3. tools/call [search] [FAIL]257 $.result.content[0].text: "old output" != "new output"258 4. tools/call [analyze] [PASS]259260Result: 3/4 passed, 1 failed261```262263Exit code is non-zero on any diff — plug it straight into CI.264265For fields that change every run, skip them by name or by exact path:266267```bash268mcp-recorder verify --cassette golden.json --target http://localhost:8000 \269 --ignore-fields timestamp \270 --ignore-paths '$.result.content[0].text.metadata.requestId'271```272273When both values are JSON-encoded strings (common in MCP `content[0].text`), mcp-recorder automatically parses and compares them structurally instead of as raw strings.274275When a change is intentional, update the cassette:276277```bash278mcp-recorder verify --cassette golden.json --target http://localhost:8000 --update279```280281### Replay282283Serve recorded responses without the real server:284285```bash286mcp-recorder replay --cassette golden.json287```288289A mock server starts on port `5555`. Point your client at it. No network, no credentials, same responses every time.290291### Inspect292293```bash294mcp-recorder inspect golden.json295```296297```298golden.json299 Recorded: 2026-02-17 20:25:23300 Server: Test Calculator v2.14.5301 Protocol: 2025-11-25302 Target: http://127.0.0.1:8000303304 Interactions (9):305 1. initialize -> 200 SSE (7ms)306 2. notifications/initialized -> 202 (1ms)307 3. tools/list -> 200 SSE (22ms)308 4. tools/call [add] -> 200 SSE (18ms)309 ...310311 Summary: 6 requests, 1 notification, 2 lifecycle312```313314## pytest Integration315316The pytest plugin activates automatically on install. Mark tests with a cassette and use the `mcp_replay_url` fixture:317318```python319import pytest320from fastmcp import Client321322@pytest.mark.mcp_cassette("cassettes/golden.json")323async def test_tool_call(mcp_replay_url):324 async with Client(mcp_replay_url) as client:325 result = await client.call_tool("add", {"a": 2, "b": 3})326 assert result.content[0].text == "5"327```328329For server regression testing, use `mcp_verify_result`:330331```python332@pytest.mark.mcp_cassette("cassettes/golden.json")333def test_no_regression(mcp_verify_result):334 assert mcp_verify_result.failed == 0, mcp_verify_result.results335```336337To ignore volatile fields, pass them via the marker:338339```python340@pytest.mark.mcp_cassette(341 "cassettes/golden.json",342 ignore_fields=["timestamp"],343 ignore_paths=["$.result.metadata.requestId"],344)345def test_no_regression(mcp_verify_result):346 assert mcp_verify_result.failed == 0347```348349```bash350pytest # replay from cassettes (default)351pytest --mcp-target http://localhost:8000 # verify against live HTTP server352pytest --mcp-target-stdio "node dist/index.js" # verify against stdio server353pytest --mcp-record-mode=auto # replay if cassette exists, skip if not354```355356Each test gets an isolated server on a random port. No manual server management.357358## Python API359360For programmatic recording:361362```python363from mcp_recorder import RecordSession364365async with RecordSession(366 target="http://localhost:8000",367 output="golden.json",368) as client:369 await client.list_tools()370 await client.call_tool("add", {"a": 2, "b": 3})371```372373`RecordSession` starts a recording proxy, runs `initialize` automatically, and saves the cassette on exit. Supports all redaction options (`redact_server_url`, `redact_env`, `redact_patterns`).374375## Configuration376377### Matching Strategies378379| Strategy | Flag | Description |380|---|---|---|381| **Method + Params** | `method_params` | Match on JSON-RPC `method` and `params`, ignoring `_meta` (default) |382| **Sequential** | `sequential` | Return next unmatched interaction in recorded order |383| **Strict** | `strict` | Full structural equality of the request body including `_meta` |384385### Secret Redaction386387Redaction is explicit — no magic scanning, no hidden behavior. You control exactly what gets scrubbed.388389**`--redact-server-url`** (enabled by default) — strips the URL path from `metadata.server_url`, keeping only scheme + host. Handles API keys in URLs like `https://mcp.firecrawl.dev/<key>/mcp`.390391```bash392mcp-recorder record --target https://mcp.firecrawl.dev/$FIRECRAWL_KEY/mcp393# metadata shows: https://mcp.firecrawl.dev/[REDACTED]394395mcp-recorder record --target http://localhost:8000 --no-redact-server-url396# metadata shows full URL397```398399**`--redact-env VAR_NAME`** — reads the env var's value and replaces it in metadata and response bodies. Request bodies are never modified to preserve replay and verify integrity.400401```bash402mcp-recorder record \403 --target https://mcp.firecrawl.dev/$FIRECRAWL_KEY/mcp \404 --redact-env FIRECRAWL_KEY405```406407**`--redact-patterns REGEX`** — for values not in environment variables. Same scope (metadata + responses only).408409```bash410mcp-recorder record --target http://localhost:8000 \411 --redact-patterns "sk-[a-zA-Z0-9]+" \412 --redact-patterns "session-[0-9a-f]{32}"413```414415In scenarios files, redaction is configured in the `redact` block and applies to all cassettes from that file. HTTP headers (Authorization, Cookie, etc.) are not stored in cassettes — the proxy only captures JSON-RPC message bodies.416417## Cassette Format418419Cassettes store JSON-RPC messages at the protocol level:420421```json422{423 "version": "1.0",424 "metadata": {425 "recorded_at": "2026-02-17T20:25:23Z",426 "server_url": "http://127.0.0.1:8000",427 "transport_type": "http",428 "protocol_version": "2025-11-25",429 "server_info": { "name": "Test Calculator", "version": "2.14.5" }430 },431 "interactions": [432 {433 "type": "jsonrpc_request",434 "request": {435 "jsonrpc": "2.0", "id": 0, "method": "initialize",436 "params": { "protocolVersion": "2025-11-25", "capabilities": {} }437 },438 "response": {439 "jsonrpc": "2.0", "id": 0,440 "result": {441 "protocolVersion": "2025-11-25",442 "capabilities": { "tools": { "listChanged": true } },443 "serverInfo": { "name": "Test Calculator", "version": "2.14.5" }444 }445 },446 "response_is_sse": true,447 "response_status": 200,448 "latency_ms": 7449 }450 ]451}452```453454The `transport_type` field (`"http"` or `"stdio"`) is informational. For stdio recordings, `response_is_sse` is `false` and `response_status` is `null` since there is no HTTP layer.455456## CLI Reference457458### `mcp-recorder record`459460| Option | Default | Description |461|---|---|---|462| `--target` | — | URL of the real MCP server (HTTP). Mutually exclusive with `--target-stdio` |463| `--target-stdio` | — | Command to spawn a stdio MCP server (e.g. `"node dist/index.js"`). Mutually exclusive with `--target` |464| `--target-env` | — | Environment variable for stdio subprocess as `KEY=VALUE`. Repeatable |465| `--port` | `5555` | Local proxy port |466| `--output` | `recording.json` | Output cassette file path |467| `--verbose` | — | Log full headers and bodies to stderr |468| `--redact-server-url / --no-redact-server-url` | `true` | Strip URL path from metadata (keeps scheme + host) |469| `--redact-env VAR` | — | Redact named env var value from metadata + responses. Repeatable |470| `--redact-patterns REGEX` | — | Redact regex matches from metadata + responses. Repeatable |471472### `mcp-recorder record-scenarios`473474| Argument / Option | Default | Description |475|---|---|---|476| `SCENARIOS_FILE` | *(required)* | Path to YAML scenarios file |477| `--output-dir` | `cassettes/` next to file | Output directory for cassettes |478| `--scenario NAME` | all | Record only the named scenario(s). Repeatable |479| `--verbose` | — | Log full request/response details to stderr |480481### `mcp-recorder replay`482483| Option | Default | Description |484|---|---|---|485| `--cassette` | *(required)* | Path to cassette file |486| `--port` | `5555` | Local server port |487| `--match` | `method_params` | Matching strategy (see [Matching Strategies](#matching-strategies)) |488| `--verbose` | — | Log every matched request to stderr |489490### `mcp-recorder verify`491492| Option | Default | Description |493|---|---|---|494| `--cassette` | *(required)* | Path to golden cassette file |495| `--target` | — | URL of the server to verify (HTTP). Mutually exclusive with `--target-stdio` |496| `--target-stdio` | — | Command to spawn a stdio MCP server. Mutually exclusive with `--target` |497| `--target-env` | — | Environment variable for stdio subprocess as `KEY=VALUE`. Repeatable |498| `--ignore-fields KEY` | — | Key name to ignore at **any depth** (e.g. `timestamp`). Repeatable |499| `--ignore-paths PATH` | — | Exact dot-path to ignore (e.g. `$.result.metadata.scrapeId`). Repeatable |500| `--update` | — | Update the cassette with new responses (snapshot update) |501| `--verbose` | — | Show full diff for each failing interaction |502503### `mcp-recorder inspect`504505| Argument | Description |506|---|---|507| `CASSETTE` | Path to cassette file to inspect |508509## CI Integration510511### GitHub Actions512513Using scenarios and verify (recommended for any language):514515```yaml516steps:517 - uses: actions/checkout@v4518 - uses: actions/setup-python@v5519 with:520 python-version: "3.12"521 - run: pip install mcp-recorder522523 # Start your MCP server524 - run: npm start &525 - run: sleep 5526527 # Verify cassettes against the live server528 - run: |529 mcp-recorder verify \530 --cassette integration/cassettes/tools_and_schemas.json \531 --target http://localhost:3000532```533534With the pytest plugin (Python projects):535536```yaml537steps:538 - uses: actions/checkout@v4539 - uses: actions/setup-python@v5540 with:541 python-version: "3.12"542 - run: pip install mcp-recorder543 - run: pytest544```545546Cassettes committed to the repo are replayed automatically. No server needed in CI for replay mode.547548## Roadmap549550- [x] `stdio` transport — subprocess wrapping for local MCP servers551- [ ] WebSocket transport552- [ ] `mcp-recorder diff` — compare two cassettes for breaking changes553- [ ] TypeScript/JS cassette support — same JSON format, Vitest/Jest plugin554555## Contributing556557```bash558git clone https://github.com/devhelmhq/mcp-recorder.git559cd mcp-recorder560uv sync --group dev561uv run pytest562```563564## License565566MIT — see [LICENSE](LICENSE) for details.567
Full transparency — inspect the skill content before installing.