mcp-cassette
Record/replay testing for MCP (Model Context Protocol) agents: capture real sessions as cassettes, replay them as deterministic mock servers — vcrpy for MCP.
A cassette records the entire session between your agent and a real MCP server — local stdio or remote Streamable HTTP — as a structured, diffable, committable file. Replayed, it stands in for the live server, so your agent test suite stops hitting live servers and stops being flaky, slow, and expensive.
mcp-cassette operates at the transport level (newline-delimited JSON-RPC over stdio; h11 + hand-rolled SSE framing over Streamable HTTP), treats messages semi-opaquely, and does not depend on the official mcp SDK at runtime — so it works with any MCP client (Claude Code included) unmodified. Sessions containing server-initiated requests (sampling, elicitation) record and replay too.
Full documentation: docs/guide/ — chapters in
reading order: getting started, then how-to guides for test authors (HT) and
troubleshooting, then installation, configuration, CI, CLI reference, and the runbook for
operators (OP). The sections below summarize; each ends with a pointer to its full chapter.
1. Install
Two situations, two different commands. Pick the one you are actually in.
Adding mcp-cassette to your own project — the normal case. It is a test-only tool, so it goes in the dev group:
uv add --dev mcp-cassette # or: pip install mcp-cassette
uv add --dev "mcp-cassette[http]" # remote (Streamable HTTP) record/replay
Writes pyproject.toml and uv.lock, and installs into .venv/. Undo: uv remove --dev mcp-cassette.
Standing in a clone of this repo — to run examples/, the CLI walkthrough, or the
test suite:
uv sync # installs this checkout plus its dev group
Writes .venv/ and (if it was stale) uv.lock; it does not touch pyproject.toml.
Undo: delete .venv/.
Do not run uv add mcp-cassette inside the clone — uv rejects it as a
self-dependency and exits 2 (self-dependencies are not permitted), and the --dev
form silently rebuilds the checkout as its own dependency instead of doing what you
meant. If you ran the --dev form here by mistake, uv remove --dev mcp-cassette
puts it back; git diff pyproject.toml is empty afterwards.
Python ≥ 3.12. Linux, macOS, and Windows supported. The core install depends only on anyio and pydantic; the [http] extra adds httpx and h11.
Run the CLI as uv run mcp-cassette ... (or activate the venv first) — either
way it lands inside a virtualenv and is not placed on PATH, so a bare
mcp-cassette is command not found. The mcp-cassette ... listings below drop the
prefix for brevity; the step-by-step walkthroughs in the guide keep it.
Full chapter: OP-01. Installation.
2. The front doors
One machinery, four ways in: the pytest fixture, the use_cassette library door and its async twin use_cassette_async, and the CLI. Same cassette format, same record modes (§3), same fault matrix (§5), same failure semantics — they differ only in who drives the session and where the cassette path comes from.
2.1 The pytest fixture (the main surface)
def test_agent_summarizes_repo(mcp_cassette):
cmd = mcp_cassette.server_command(["python", "tools/github_server.py"])
result = run_my_agent(mcp_servers={"github": cmd})
assert "summary" in result
First run records through the recording proxy; every run after replays offline, deterministic and fast. The fixture never monkeypatches your agent — it hands you a command list to plug into the agent's MCP server configuration.
run_my_agent is a stand-in for your agent — there is nothing to install for it. If you
have no agent yet and want to run this door now,
examples/test_echo.py
is the same test with a minimal client in that slot, runnable from a clone with
uv run pytest examples/test_echo.py. examples/ ships in the repo, not in the
package — from your own project, the
CLI walkthrough in docs/guide/getting-started.md
proves the same loop with a paste-able server and no agent of your own.
For a remote server, server_url is the drop-in twin (needs the [http] extra):
def test_agent_reads_remote_tracker(mcp_cassette):
url = mcp_cassette.server_url("https://mcp.example.com/mcp")
result = run_my_agent(mcp_servers={"tracker": {"url": url}})
assert "triaged" in result
First run stands up a local recording proxy in front of the real URL; every run after replays from the cassette on a local mock Streamable HTTP server. Same record modes, same fault matrix. Authorization (and every other header) is forwarded upstream but never written to the cassette.
Full chapters: HT-01. Record and replay a stdio server, HT-02. Record and replay a remote HTTP server.
2.2 Use it as a library
Not a pytest suite? use_cassette is the same machinery behind a context manager — same modes, same fault matrix, same failure semantics:
from mcp_cassette import use_cassette
with use_cassette("cassettes/search.mcp.json", mode="once") as session:
cmd = session.server_command(["python", "-m", "my_server"])
run_my_agent(mcp_servers={"search": {"command": cmd[0], "args": cmd[1:]}})
# clean exit -> finalize(): background server stopped, report checked,
# CassetteError raised on an empty recording or any replay miss
The session report goes to a temp directory that is removed on exit — no untracked JSON next to cassettes you commit. examples/library_mode.py is runnable from a clone.
From async code, use_cassette_async runs the HTTP server as a task in your own event loop, with no background thread, under asyncio or trio. Calling the sync use_cassette from inside a running loop raises RuntimeError pointing here.
from mcp_cassette import use_cassette_async
async with use_cassette_async("cassettes/tracker.mcp.json") as session:
url = session.server_url("https://mcp.example.com/mcp")
result = await run_my_agent(mcp_servers={"tracker": {"url": url}})
examples/library_mode_async.py is runnable from a clone.
Full chapter: HT-03. Use it as a library.
2.3 The CLI
The listing below is a map of the surface, not a runnable recipe —
tools/server.py is a stand-in for your own server, and record captures nothing
unless a client drives it. For commands you can paste as-is, use
the CLI walkthrough in docs/guide/getting-started.md
— it carries a 30-line paste-able server, so it runs in your own project as well as in
a clone — or examples/README.md,
which needs a clone because it drives the bundled echo server.
mcp-cassette record --cassette demo.json -- python tools/server.py # wrap a real server
mcp-cassette record --cassette demo.json --url https://mcp.example.com/mcp # proxy a remote one
mcp-cassette serve demo.json # drop-in replay server (transport inferred)
mcp-cassette serve demo.json --faults demo.faults.json # replay with faults
mcp-cassette inspect demo.json # per-method counts + timing
mcp-cassette inspect demo.json --timeline --grep 'tools/call' # message timeline, payload-grepped
mcp-cassette inspect demo.json --format json > summary.json # deterministic, diffable
mcp-cassette inspect demo.json --faults demo.faults.json # dry-run: which requests a fault hits
mcp-cassette diff old.json new.json --tools-only # exit 5 when the server surface moved
A recording is checkpointed to a <cassette>.partial sidecar every 5 seconds (--checkpoint-interval SECONDS, 0 disables), so a hard kill loses only what arrived since the last checkpoint. The sidecar is a valid cassette — see §OP-02.6 Checkpointing for recovery and why it is never written to the cassette path itself.
inspect, lint (§8.1), and diff (§8.2) are CLI-only workflows in practice, but not CLI-only code: lint_cassette and diff_cassettes are exported from the package for scripted gates.
Full chapter: OP-04. CLI reference.
3. Record modes
The mode decides, once per test run, whether that run records or replays; the recording unit is always the entire session — every message from server launch to session end — never an individual tool call.
| Mode | Cassette absent | Cassette present |
|---|---|---|
once (default) |
record | replay |
none |
fail — recording is forbidden | replay |
all |
record | re-record |
new_episodes |
record | replay, then append misses |
Under new_episodes, a request the cassette cannot answer falls through to the real
server and the resulting exchange is appended to the cassette.
How each door selects a mode, highest precedence first:
| Door | Selection |
|---|---|
| pytest fixture | MCP_CASSETTE_MODE (env) → marker mode= → mcp_cassette_mode (ini) → default once |
use_cassette, use_cassette_async |
MCP_CASSETTE_MODE (env) → mode= argument → default once |
| CLI | explicit by command: record records, serve replays, serve --new-episodes appends misses |
CI should set MCP_CASSETTE_MODE=none so no pipeline silently hits a live server — the env var wins through both programmatic doors, and the CLI has no door that records by accident.
Cassette paths come from the marker's cassette=, else mcp_cassette_dir (ini), else <rootpath>/tests/cassettes; pytest -o mcp_cassette_dir=... overrides it for one invocation. That setting is fixture-only, and deliberately has no env var — the fixture is the one door that derives a path from the test name. The CLI and use_cassette take the full path from you, so you compose the directory into it yourself.
3.1 Parallel runs: one writer per cassette
Two processes recording the same cassette at once would interleave or truncate it, so a run that writes takes a <cassette>.claim file first. Readers never claim, so replay-only suites are unaffected and never serialize.
| Mode | Another live process holds the claim |
|---|---|
once |
wait, then re-resolve — the other writer's recording is now present, so this run replays it |
all, new_episodes |
fail fast: exit 6 from the CLI, CassetteError otherwise |
once waiting and then replaying is the behaviour that makes pytest -n auto work on a cold cache: the first worker records, the rest find the cassette and play it back. Sessions wait 30s; the CLI fails immediately unless record --claim-wait SECONDS asks it to wait. A claim whose owner died is reclaimed automatically, and record --force breaks a live one with a warning.
Full chapters: OP-02. Configuration and OP-06. Parallel test runs.
4. The CI contract: record once, commit, replay forever
Three steps, and the third is the one that has to be enforced:
- Record once, locally, against the real server. Default
oncemode does this when no cassette exists. - Commit the cassette. Review it like code — a changed tool description is a supply-chain event, not a fixture edit.
- CI only replays and lints.
MCP_CASSETTE_MODE=nonemakes a missing cassette a red build instead of a live call with production credentials.
examples/cassettes/echo_and_add.mcp.json is this repo's golden cassette — the committed recording behind test_echo_and_add. Prove replay-only mode against it, one test or the whole directory:
MCP_CASSETTE_MODE=none uv run pytest examples/test_echo.py -q # one file: 4 passed
MCP_CASSETTE_MODE=none uv run pytest examples/ -q # all examples: 28 passed
Both run with no server, no network, and no credentials. Under none, a deleted or unmerged cassette fails with no cassette at <path> and recording is forbidden — delete one on a scratch branch to see it.
Full chapter: OP-03. CI pipeline.
5. Fault injection
One recorded cassette drives a whole resilience matrix:
import mcp_cassette as mcc
@pytest.mark.parametrize("fault", [
mcc.Fault.timeout("tools/call", nth=1),
mcc.Fault.error("tools/call", code=-32000, message="rate limited"),
mcc.Fault.disconnect("tools/call"),
])
def test_agent_survives_tool_trouble(mcp_cassette, fault):
session = mcp_cassette.with_faults(fault)
cmd = session.server_command(["python", "tools/github_server.py"])
result = run_my_agent(mcp_servers={"github": cmd})
assert result.completed_with_degraded_tools
Fault types: delay, timeout, error, malformed, disconnect. Faults live in a FaultOverlay; the recorded cassette is never mutated.
Every door can inject them: with_faults(...) on the fixture session, use_cassette(..., faults=FaultOverlay(...)) in library code, and serve --faults <overlay>.json on the CLI — the fixture just writes your overlay to a temp file and passes that same flag. Faults are replay-only, on every door: with_faults under a recording mode raises (new_episodes counts as recording), and serve --new-episodes --faults is a usage error.
Faults fire on the response side, after a request matched a recorded exchange — there is no fault that corrupts the request on its way in. An unmatched request never reaches the injector at all: the client gets a -32001 unmatched error and the server exits 3, whether or not a fault targeted that method. inspect --faults dry-runs which recorded requests an overlay would hit, and a fault that never fired warns at shutdown, so a typo'd method name is visible instead of silent.
Full chapter: HT-04. Inject faults.
6. Replay timing
Replay is instant by default. When your agent's timeout, progress-stream, or retry logic depends on how long the server took, replay the recorded gaps instead:
mcp-cassette serve demo.json --pace recorded # recorded latency
mcp-cassette serve demo.json --pace recorded --pace-scale 0.2 # 5x faster
Also @pytest.mark.mcp_cassette(pace="recorded", pace_scale=0.2) and use_cassette(..., pace=PaceConfig(mode="recorded")). Per-gap cap defaults to 5000 ms so one pathological recorded pause cannot look like a hung job; --pace-cap-ms 0 opts into uncapped. A delay fault stacks on top of recorded latency.
Full chapter: HT-05. Replay timing.
7. Redaction
Cassettes are verbatim transcripts, and you commit them — so redaction runs at capture time, on a deep copy, with defaults always on: values under keys matching *token*, *secret*, *password*, *apikey*, *api_key*, or authorization (with - and _ folded, so X-API-Key matches) are replaced with REDACTED before the cassette is written. Add your own rules with --redact (key-glob or JSON pointer).
Free text needs a different tool: an email inside a search result has no key. A bundled redaction pack (email, phone, IP, SSN, IBAN, card, AWS key id, private key, JWT) is also on by default, and your own TOML packs add labelled regexes with a replace, hash, or mask strategy:
mcp-cassette record --cassette demo.json --pii-pack team.toml --redact-profile team-baseline -- python tools/server.py
mcp-cassette lint demo.json --require-redaction team-baseline # exit 4 unless scrubbed with that profile
The cassette records which packs scrubbed it, and replay re-applies them to live requests so a scrubbed recording still matches. From Python, pass pii_packs=[...] to the marker, use_cassette, or use_cassette_async. Read every new cassette before its first commit anyway.
Full chapters: HT-07. Redact secrets, HT-10. Redact PII from free text.
8. Gating your cassettes
Two gates, and they cover each other's blind spots: lint catches text that looks hostile, diff catches a surface that moved. A poisoned description that was there from the first recording never moves; an innocuous new parameter never looks hostile.
Both run against a cassette file, not a session, so they belong in CI next to your test run rather than inside it.
8.1 Lint: injection smells
Recorded tool descriptions and results are third-party content; lint them in CI before they reach a model:
mcp-cassette lint demo-http.json
mcp-cassette lint new.json --baseline tests/cassettes/old.json --format json
Rules: R001 instruction injection in a tool description (error), R002 description/schema drift vs a baseline — the "rug pull" (error), R003 duplicate tool names (warning), R004 instruction-shaped tool results (warning), R005 high-entropy strings that look like secrets, anywhere in the cassette (warning), R006 injection phrasing in tool names and inputSchema text (warning), R007 non-ASCII or mixed-script tool names (warning). Exit 0 = no error-severity findings, 4 = at least one. Each finding carries a JSON-pointer locator into the cassette.
Bring your own rules with a declarative TOML pattern pack — no Python plugin API, deliberately, because lint should never execute third-party code on a supply-chain-security surface:
mcp-cassette lint demo.json --pattern-pack examples/lint-pack.toml
mcp-cassette lint demo.json --fail-on warning
[tool.mcp_cassette.lint] in pyproject.toml makes your packs, selection, and failure threshold the default for every invocation, so the CI command stays generic. Packs extend the bundled rules; they never replace them.
A pack adds patterns: it can target tool names, descriptions, inputSchema descriptions and enum values (from tools/list), and tool result text (from tools/call). Whether a tool's inputSchema changed between two recordings is compared, not pattern-matched — that is R002's and diff's job.
Heuristic pattern rules, not a guarantee — a clean lint is the absence of known smells, nothing more.
8.2 Diff: a drifting server surface
diff --tools-only exits 5 when a tool's description or schema moved between two recordings — including changes that carry no smell at all. Committed cassettes make the whole gate runnable from a clone, with no server and no network:
mcp-cassette lint examples/cassettes/tools.mcp.json # clean: exit 0
mcp-cassette lint examples/cassettes/tools-v2.mcp.json # injected description: exit 4
mcp-cassette diff examples/cassettes/tools.mcp.json \
examples/cassettes/tools-v2.mcp.json --tools-only # surface moved: exit 5
tools-v2 is the same server one version later. Its description picked up an injection payload, and echo grew a callback_url parameter. The new parameter reads as perfectly innocuous, so lint never flags it — only diff does. Both steps run in this repo's CI, asserted by exit code.
A red drift gate is not a failure to re-record away: read the diff, decide whether you accept the new surface, and only then commit the fresh cassette as the new baseline.
Full chapters: HT-08. Lint with your own pattern packs, HT-09. Gate a drifting server surface.
8.3 Packaged CI: a GitHub Action and pre-commit hooks
Both gates ship packaged, version-locked to the library. The Action lints every cassette against the same file on the pull request's base branch, so R002 fires on the diff that introduces the drift, and posts findings as annotations, a job summary, and a JSON report artifact:
# .github/workflows/cassettes.yml
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # required: the merge-base baseline needs history
- uses: cheneeheng/mcp-cassette@v0.4.0
with:
checks: lint,diff
require-redaction: team-baseline
pattern-packs: lint/packs/team.toml
The pre-commit hooks refuse an unclean or unscrubbed cassette before it is ever committed. They read local files only, never the network:
# .pre-commit-config.yaml
- repo: https://github.com/cheneeheng/mcp-cassette
rev: v0.4.0
hooks:
- id: mcp-cassette-lint
- id: mcp-cassette-redaction-check
args: [team-baseline]
Both files are runnable in examples/ci/. Running the suite with pytest -n auto? Replay is always safe to share, and concurrent recordings to one cassette are detected rather than silently lost.
Full chapters: OP-03. CI pipeline, OP-06. Parallel test runs, OP-07. Pre-commit hooks.
9. Built with Claude Code
mcp-cassette was built with the help of Claude Code under human direction and review.
It is an independent open-source project: not an Anthropic product, and not affiliated with or endorsed by Anthropic. "Model Context Protocol" and "Claude" are Anthropic's.
10. License
Apache-2.0 — see LICENSE.
Metadata
Release files for mcp-cassette 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mcp_cassette-0.4.0.tar.gz | 381.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mcp_cassette-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 513.6 kB
Release files / mcp_cassette-0.4.0.tar.gz
| Download URL | mcp_cassette-0.4.0.tar.gz |
|---|---|
| Size | 381.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
48d6ba9e5b74230739bedd458560a723685e082bfda407080d6c8c16a99490e0
|
|
BLAKE2b-256 checksum How to use checksums |
e6597204e7b34f4e25be339df98a710839002709d6386b7532dae7e683e62a34
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency logRelease files / mcp_cassette-0.4.0-py3-none-any.whl
| Download URL | mcp_cassette-0.4.0-py3-none-any.whl |
|---|---|
| Size | 132.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bf82c1a0283f1d36614c8801cb24e6aa01238f5d7d56873edd26be8b3656018e
|
|
BLAKE2b-256 checksum How to use checksums |
727e7bf9915c4932670cb678ab4b83eabb7c79b57a8d631228b68de7bd9331db
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 19, 2026.
Transparency log