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 server at https://mcp.devhelm.io are included so you can run this without any setup:
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.
- Install
- How It Works
- Scenarios
- CLI Usage
- pytest Integration
- Python API
- Configuration
- Cassette Format
- CLI Reference
- CI Integration
- Roadmap
- Contributing
pip install mcp-recorderOr with uv:
uv add mcp-recordermcp-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.
Define what to test in a YAML file. No Python scripts, no boilerplate — works with MCP servers written in any language.
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:
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 |
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.
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:
# .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:
mcp-recorder record-scenarios scenarios.yml
mcp-recorder record-scenarios scenarios.yml --scenario tools_and_schemasEach 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 |
Start the proxy pointing at your MCP server:
# 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.jsonPoint your MCP client at http://localhost:5555 and interact normally. Press Ctrl+C when done — the cassette is saved.
Works with remote servers too:
mcp-recorder record \
--target https://mcp.example.com/v1/mcp \
--redact-env API_KEY \
--output golden.jsonFor automated recording, see Scenarios.
After making changes to your server, verify nothing broke:
# 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-keyVerifying 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:
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:
mcp-recorder verify --cassette golden.json --target http://localhost:8000 --updateServe recorded responses without the real server:
mcp-recorder replay --cassette golden.jsonA mock server starts on port 5555. Point your client at it. No network, no credentials, same responses every time.
mcp-recorder inspect golden.jsongolden.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
The pytest plugin activates automatically on install. Mark tests with a cassette and use the mcp_replay_url fixture:
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:
@pytest.mark.mcp_cassette("cassettes/golden.json")
def test_no_regression(mcp_verify_result):
assert mcp_verify_result.failed == 0, mcp_verify_result.resultsTo ignore volatile fields, pass them via the marker:
@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 == 0pytest # 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 notEach test gets an isolated server on a random port. No manual server management.
For programmatic recording:
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).
| 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 |
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/<key>/mcp.
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.
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).
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.
Cassettes store JSON-RPC messages at the protocol level:
{
"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.
| 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 |
| 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 |
| Option | Default | Description |
|---|---|---|
--cassette |
(required) | Path to cassette file |
--port |
5555 |
Local server port |
--match |
method_params |
Matching strategy (see Matching Strategies) |
--verbose |
— | Log every matched request to stderr |
| 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 |
| Argument | Description |
|---|---|
CASSETTE |
Path to cassette file to inspect |
Using scenarios and verify (recommended for any language):
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:3000With the pytest plugin (Python projects):
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install mcp-recorder
- run: pytestCassettes committed to the repo are replayed automatically. No server needed in CI for replay mode.
-
stdiotransport — 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
git clone https://github.com/devhelmhq/mcp-recorder.git
cd mcp-recorder
uv sync --group dev
uv run pytestMIT — see LICENSE for details.
