https://github.com/devhelmhq/mcp-recorder
A lightweight proxy that records and replays MCP server interactions for deterministic testing and CI-friendly regression workflows.
https://github.com/devhelmhq/mcp-recorder
developer-tools mcp mcp-testing testing-tools
Last synced: 2 months ago
JSON representation
A lightweight proxy that records and replays MCP server interactions for deterministic testing and CI-friendly regression workflows.
- Host: GitHub
- URL: https://github.com/devhelmhq/mcp-recorder
- Owner: devhelmhq
- License: mit
- Created: 2026-02-16T18:02:08.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-03-06T10:55:05.000Z (4 months ago)
- Last Synced: 2026-03-06T14:52:47.924Z (4 months ago)
- Topics: developer-tools, mcp, mcp-testing, testing-tools
- Language: Python
- Homepage: https://devhelm.io
- Size: 2.42 MB
- Stars: 7
- Watchers: 0
- Forks: 0
- Open Issues: 3
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Security: .github/SECURITY.md
Awesome Lists containing this project
README
# mcp-recorder — VCR.py for MCP servers
Record, replay, and verify Model Context Protocol interactions for deterministic testing.
[](https://pypi.org/project/mcp-recorder/)
[](https://python.org)
[](LICENSE)
[](https://github.com/devhelmhq/mcp-recorder/actions)
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.
## Record. Replay. Verify.
Try 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:
```bash
pip install mcp-recorder
# 1. Record cassettes from a scenarios file (zero code)
mcp-recorder record-scenarios scenarios.yml
# 2. Inspect what was captured
mcp-recorder inspect cassettes/demo_walkthrough.json
# 3. Verify your server hasn't regressed — compare responses to the recording
mcp-recorder verify --cassette cassettes/demo_walkthrough.json --target https://mcp.devhelm.io
# 4. Replay as a mock server — test your client without the real server
# (starts a local server on port 5555, point your MCP client at it)
mcp-recorder replay --cassette cassettes/demo_walkthrough.json
# Works with stdio servers too — no HTTP wrapper needed
mcp-recorder verify --cassette cassettes/golden.json \
--target-stdio "node dist/index.js"
```
One cassette. Three modes. HTTP and stdio transports. Full coverage for both client and server testing.
## Contents
- [Install](#install)
- [How It Works](#how-it-works)
- [Scenarios](#scenarios)
- [CLI Usage](#cli-usage)
- [pytest Integration](#pytest-integration)
- [Python API](#python-api)
- [Configuration](#configuration)
- [Cassette Format](#cassette-format)
- [CLI Reference](#cli-reference)
- [CI Integration](#ci-integration)
- [Roadmap](#roadmap)
- [Contributing](#contributing)
## Install
```bash
pip install mcp-recorder
```
Or with [uv](https://docs.astral.sh/uv/):
```bash
uv add mcp-recorder
```
## How It Works
mcp-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:
```
Record: Client -> mcp-recorder (proxy) -> Real Server -> cassette.json
(HTTP or stdio subprocess)
Replay: Client -> mcp-recorder (mock) -> cassette.json (test your client)
Verify: mcp-recorder (client mock) -> Real Server (test your server)
```
**Replay** serves recorded responses back to your client. No real server, no credentials, no network.
**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.
## Scenarios
Define what to test in a YAML file. No Python scripts, no boilerplate — works with MCP servers written in any language.
```yaml
schema_version: "1.0"
target: http://localhost:3000
redact:
server_url: true
env:
- API_KEY
patterns:
- "sk-[a-zA-Z0-9]+"
scenarios:
tools_and_schemas:
description: "Discover tools and call search"
actions:
- list_tools
- call_tool:
name: search
arguments:
query: "test"
error_handling:
description: "Invalid inputs return proper errors"
actions:
- call_tool:
name: search
arguments: {}
```
For stdio MCP servers, use a target object instead of a URL:
```yaml
target:
command: "node"
args: ["dist/index.js"]
env:
API_KEY: "test-key"
cwd: "./server"
```
| Target field | Required | Description |
|---|---|---|
| `command` | yes | Executable to spawn |
| `args` | no | List of command-line arguments |
| `env` | no | Extra environment variables (merged with current env) |
| `cwd` | no | Working directory for the subprocess |
### Environment Variables
String 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.
```yaml
schema_version: "1.0"
target:
command: "node"
args: ["dist/index.js"]
env:
API_KEY: "${API_KEY}"
REGION: "${AWS_REGION:-us-east-1}"
redact:
env:
- API_KEY
scenarios:
authenticated_search:
description: "Search with a real API key"
actions:
- list_tools
- call_tool:
name: search
arguments:
query: "test"
```
This works naturally with CI systems. In GitHub Actions, expose repository secrets as environment variables and `scenarios.yml` picks them up:
```yaml
# .github/workflows/mcp-test.yml
jobs:
test:
runs-on: ubuntu-latest
env:
API_KEY: ${{ secrets.API_KEY }}
steps:
- uses: actions/checkout@v4
- run: pip install mcp-recorder
- run: mcp-recorder record-scenarios scenarios.yml -o cassettes/
```
Interpolation 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.
Record all scenarios at once, or pick one:
```bash
mcp-recorder record-scenarios scenarios.yml
mcp-recorder record-scenarios scenarios.yml --scenario tools_and_schemas
```
Each scenario key becomes the cassette filename (`tools_and_schemas` -> `tools_and_schemas.json`). Protocol handshake (`initialize` + `notifications/initialized`) is handled automatically.
Supported actions:
| Action | Description |
|---|---|
| `list_tools` | Call `tools/list` |
| `call_tool` | Call `tools/call` with `name` and `arguments` |
| `list_prompts` | Call `prompts/list` |
| `get_prompt` | Call `prompts/get` with `name` and optional `arguments` |
| `list_resources` | Call `resources/list` |
| `read_resource` | Call `resources/read` with `uri` |
## CLI Usage
### Interactive Recording
Start the proxy pointing at your MCP server:
```bash
# HTTP target
mcp-recorder record \
--target http://localhost:8000 \
--port 5555 \
--output golden.json
# stdio target — spawns the server as a subprocess
mcp-recorder record \
--target-stdio "node dist/index.js" \
--target-env API_KEY=test-key \
--output golden.json
```
Point your MCP client at `http://localhost:5555` and interact normally. Press `Ctrl+C` when done — the cassette is saved.
Works with remote servers too:
```bash
mcp-recorder record \
--target https://mcp.example.com/v1/mcp \
--redact-env API_KEY \
--output golden.json
```
For automated recording, see [Scenarios](#scenarios).
### Verify
After making changes to your server, verify nothing broke:
```bash
# HTTP target
mcp-recorder verify --cassette golden.json --target http://localhost:8000
# stdio target
mcp-recorder verify --cassette golden.json \
--target-stdio "node dist/index.js" \
--target-env API_KEY=test-key
```
```
Verifying golden.json against http://localhost:8000
1. initialize [PASS]
2. tools/list [PASS]
3. tools/call [search] [FAIL]
$.result.content[0].text: "old output" != "new output"
4. tools/call [analyze] [PASS]
Result: 3/4 passed, 1 failed
```
Exit code is non-zero on any diff — plug it straight into CI.
For fields that change every run, skip them by name or by exact path:
```bash
mcp-recorder verify --cassette golden.json --target http://localhost:8000 \
--ignore-fields timestamp \
--ignore-paths '$.result.content[0].text.metadata.requestId'
```
When 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.
When a change is intentional, update the cassette:
```bash
mcp-recorder verify --cassette golden.json --target http://localhost:8000 --update
```
### Replay
Serve recorded responses without the real server:
```bash
mcp-recorder replay --cassette golden.json
```
A mock server starts on port `5555`. Point your client at it. No network, no credentials, same responses every time.
### Inspect
```bash
mcp-recorder inspect golden.json
```
```
golden.json
Recorded: 2026-02-17 20:25:23
Server: Test Calculator v2.14.5
Protocol: 2025-11-25
Target: http://127.0.0.1:8000
Interactions (9):
1. initialize -> 200 SSE (7ms)
2. notifications/initialized -> 202 (1ms)
3. tools/list -> 200 SSE (22ms)
4. tools/call [add] -> 200 SSE (18ms)
...
Summary: 6 requests, 1 notification, 2 lifecycle
```
## pytest Integration
The pytest plugin activates automatically on install. Mark tests with a cassette and use the `mcp_replay_url` fixture:
```python
import pytest
from fastmcp import Client
@pytest.mark.mcp_cassette("cassettes/golden.json")
async def test_tool_call(mcp_replay_url):
async with Client(mcp_replay_url) as client:
result = await client.call_tool("add", {"a": 2, "b": 3})
assert result.content[0].text == "5"
```
For server regression testing, use `mcp_verify_result`:
```python
@pytest.mark.mcp_cassette("cassettes/golden.json")
def test_no_regression(mcp_verify_result):
assert mcp_verify_result.failed == 0, mcp_verify_result.results
```
To ignore volatile fields, pass them via the marker:
```python
@pytest.mark.mcp_cassette(
"cassettes/golden.json",
ignore_fields=["timestamp"],
ignore_paths=["$.result.metadata.requestId"],
)
def test_no_regression(mcp_verify_result):
assert mcp_verify_result.failed == 0
```
```bash
pytest # replay from cassettes (default)
pytest --mcp-target http://localhost:8000 # verify against live HTTP server
pytest --mcp-target-stdio "node dist/index.js" # verify against stdio server
pytest --mcp-record-mode=auto # replay if cassette exists, skip if not
```
Each test gets an isolated server on a random port. No manual server management.
## Python API
For programmatic recording:
```python
from mcp_recorder import RecordSession
async with RecordSession(
target="http://localhost:8000",
output="golden.json",
) as client:
await client.list_tools()
await client.call_tool("add", {"a": 2, "b": 3})
```
`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`).
## Configuration
### Matching Strategies
| Strategy | Flag | Description |
|---|---|---|
| **Method + Params** | `method_params` | Match on JSON-RPC `method` and `params`, ignoring `_meta` (default) |
| **Sequential** | `sequential` | Return next unmatched interaction in recorded order |
| **Strict** | `strict` | Full structural equality of the request body including `_meta` |
### Secret Redaction
Redaction is explicit — no magic scanning, no hidden behavior. You control exactly what gets scrubbed.
**`--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//mcp`.
```bash
mcp-recorder record --target https://mcp.firecrawl.dev/$FIRECRAWL_KEY/mcp
# metadata shows: https://mcp.firecrawl.dev/[REDACTED]
mcp-recorder record --target http://localhost:8000 --no-redact-server-url
# metadata shows full URL
```
**`--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.
```bash
mcp-recorder record \
--target https://mcp.firecrawl.dev/$FIRECRAWL_KEY/mcp \
--redact-env FIRECRAWL_KEY
```
**`--redact-patterns REGEX`** — for values not in environment variables. Same scope (metadata + responses only).
```bash
mcp-recorder record --target http://localhost:8000 \
--redact-patterns "sk-[a-zA-Z0-9]+" \
--redact-patterns "session-[0-9a-f]{32}"
```
In 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.
## Cassette Format
Cassettes store JSON-RPC messages at the protocol level:
```json
{
"version": "1.0",
"metadata": {
"recorded_at": "2026-02-17T20:25:23Z",
"server_url": "http://127.0.0.1:8000",
"transport_type": "http",
"protocol_version": "2025-11-25",
"server_info": { "name": "Test Calculator", "version": "2.14.5" }
},
"interactions": [
{
"type": "jsonrpc_request",
"request": {
"jsonrpc": "2.0", "id": 0, "method": "initialize",
"params": { "protocolVersion": "2025-11-25", "capabilities": {} }
},
"response": {
"jsonrpc": "2.0", "id": 0,
"result": {
"protocolVersion": "2025-11-25",
"capabilities": { "tools": { "listChanged": true } },
"serverInfo": { "name": "Test Calculator", "version": "2.14.5" }
}
},
"response_is_sse": true,
"response_status": 200,
"latency_ms": 7
}
]
}
```
The `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.
## CLI Reference
### `mcp-recorder record`
| Option | Default | Description |
|---|---|---|
| `--target` | — | URL of the real MCP server (HTTP). Mutually exclusive with `--target-stdio` |
| `--target-stdio` | — | Command to spawn a stdio MCP server (e.g. `"node dist/index.js"`). Mutually exclusive with `--target` |
| `--target-env` | — | Environment variable for stdio subprocess as `KEY=VALUE`. Repeatable |
| `--port` | `5555` | Local proxy port |
| `--output` | `recording.json` | Output cassette file path |
| `--verbose` | — | Log full headers and bodies to stderr |
| `--redact-server-url / --no-redact-server-url` | `true` | Strip URL path from metadata (keeps scheme + host) |
| `--redact-env VAR` | — | Redact named env var value from metadata + responses. Repeatable |
| `--redact-patterns REGEX` | — | Redact regex matches from metadata + responses. Repeatable |
### `mcp-recorder record-scenarios`
| Argument / Option | Default | Description |
|---|---|---|
| `SCENARIOS_FILE` | *(required)* | Path to YAML scenarios file |
| `--output-dir` | `cassettes/` next to file | Output directory for cassettes |
| `--scenario NAME` | all | Record only the named scenario(s). Repeatable |
| `--verbose` | — | Log full request/response details to stderr |
### `mcp-recorder replay`
| Option | Default | Description |
|---|---|---|
| `--cassette` | *(required)* | Path to cassette file |
| `--port` | `5555` | Local server port |
| `--match` | `method_params` | Matching strategy (see [Matching Strategies](#matching-strategies)) |
| `--verbose` | — | Log every matched request to stderr |
### `mcp-recorder verify`
| Option | Default | Description |
|---|---|---|
| `--cassette` | *(required)* | Path to golden cassette file |
| `--target` | — | URL of the server to verify (HTTP). Mutually exclusive with `--target-stdio` |
| `--target-stdio` | — | Command to spawn a stdio MCP server. Mutually exclusive with `--target` |
| `--target-env` | — | Environment variable for stdio subprocess as `KEY=VALUE`. Repeatable |
| `--ignore-fields KEY` | — | Key name to ignore at **any depth** (e.g. `timestamp`). Repeatable |
| `--ignore-paths PATH` | — | Exact dot-path to ignore (e.g. `$.result.metadata.scrapeId`). Repeatable |
| `--update` | — | Update the cassette with new responses (snapshot update) |
| `--verbose` | — | Show full diff for each failing interaction |
### `mcp-recorder inspect`
| Argument | Description |
|---|---|
| `CASSETTE` | Path to cassette file to inspect |
## CI Integration
### GitHub Actions
Using scenarios and verify (recommended for any language):
```yaml
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install mcp-recorder
# Start your MCP server
- run: npm start &
- run: sleep 5
# Verify cassettes against the live server
- run: |
mcp-recorder verify \
--cassette integration/cassettes/tools_and_schemas.json \
--target http://localhost:3000
```
With the pytest plugin (Python projects):
```yaml
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install mcp-recorder
- run: pytest
```
Cassettes committed to the repo are replayed automatically. No server needed in CI for replay mode.
## Roadmap
- [x] `stdio` transport — subprocess wrapping for local MCP servers
- [ ] WebSocket transport
- [ ] `mcp-recorder diff` — compare two cassettes for breaking changes
- [ ] TypeScript/JS cassette support — same JSON format, Vitest/Jest plugin
## Contributing
```bash
git clone https://github.com/devhelmhq/mcp-recorder.git
cd mcp-recorder
uv sync --group dev
uv run pytest
```
## License
MIT — see [LICENSE](LICENSE) for details.