Skip to content

devhelmhq/mcp-recorder

Repository files navigation

mcp-recorder demo

mcp-recorder — VCR.py for MCP servers

Record, replay, and verify Model Context Protocol interactions for deterministic testing.

PyPI version Python 3.11+ License: MIT CI

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

Contents

Install

pip install mcp-recorder

Or with uv:

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.

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

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.

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

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

mcp-recorder record \
  --target https://mcp.example.com/v1/mcp \
  --redact-env API_KEY \
  --output golden.json

For automated recording, see Scenarios.

Verify

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

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

Replay

Serve recorded responses without the real server:

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

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:

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.results

To 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 == 0
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:

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/<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.

Cassette Format

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.

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

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

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

  • 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

git clone https://github.com/devhelmhq/mcp-recorder.git
cd mcp-recorder
uv sync --group dev
uv run pytest

License

MIT — see LICENSE for details.

About

A lightweight proxy that records and replays MCP server interactions for deterministic testing and CI-friendly regression workflows.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages