Metadata-Version: 2.5
Name: mcp-cassette
Version: 0.4.0
Summary: Record/replay testing for MCP (Model Context Protocol) agents: capture real sessions as cassettes, replay them as deterministic mock servers — vcrpy for MCP.
Project-URL: Homepage, https://github.com/cheneeheng/mcp-cassette
Project-URL: Repository, https://github.com/cheneeheng/mcp-cassette
Project-URL: Documentation, https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/index.md
Project-URL: Changelog, https://github.com/cheneeheng/mcp-cassette/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/cheneeheng/mcp-cassette/issues
Author-email: EeHeng Chen <eeheng.chen@gmail.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agent,mcp,mock,model-context-protocol,pytest,record-replay,testing,vcr
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Testing
Classifier: Topic :: Software Development :: Testing :: Mocking
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: anyio>=4.11
Requires-Dist: pydantic>=2.0
Provides-Extra: http
Requires-Dist: h11>=0.14; extra == 'http'
Requires-Dist: httpx>=0.27; extra == 'http'
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == 'test'
Description-Content-Type: text/markdown

# mcp-cassette

[![CI](https://github.com/cheneeheng/mcp-cassette/actions/workflows/ci.yml/badge.svg)](https://github.com/cheneeheng/mcp-cassette/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/mcp-cassette.svg)](https://pypi.org/project/mcp-cassette/)
[![Python](https://img.shields.io/pypi/pyversions/mcp-cassette.svg)](https://pypi.org/project/mcp-cassette/)
[![License](https://img.shields.io/pypi/l/mcp-cassette.svg)](https://github.com/cheneeheng/mcp-cassette/blob/main/LICENSE)

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/](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/index.md)** — 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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/operations/OP-01-install.md).

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

```python
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`](https://github.com/cheneeheng/mcp-cassette/blob/main/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`](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/getting-started.md#first-run-with-the-cli)
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):

```python
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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/how-to/HT-01-record-and-replay.md), [HT-02. Record and replay a remote HTTP server](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/how-to/HT-02-remote-http.md).

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

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

```python
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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/how-to/HT-03-use-as-a-library.md).

### 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`](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/getting-started.md#first-run-with-the-cli)
— 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`](https://github.com/cheneeheng/mcp-cassette/blob/main/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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/operations/OP-02-configure.md#op-026-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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/operations/OP-04-cli-reference.md).

## 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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/operations/OP-02-configure.md) and [OP-06. Parallel test runs](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/operations/OP-06-parallel-test-runs.md).

## 4. The CI contract: record once, commit, replay forever

Three steps, and the third is the one that has to be enforced:

1. **Record once, locally**, against the real server. Default `once` mode does this when no cassette exists.
2. **Commit the cassette.** Review it like code — a changed tool description is a supply-chain event, not a fixture edit.
3. **CI only replays and lints.** `MCP_CASSETTE_MODE=none` makes 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:

```bash
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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/operations/OP-03-ci.md).

## 5. Fault injection

One recorded cassette drives a whole resilience matrix:

```python
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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/how-to/HT-04-inject-faults.md).

## 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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/how-to/HT-05-replay-timing.md).

## 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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/how-to/HT-07-redact-secrets.md), [HT-10. Redact PII from free text](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/how-to/HT-10-redact-pii.md).

## 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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/how-to/HT-08-lint-pattern-packs.md), [HT-09. Gate a drifting server surface](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/how-to/HT-09-gate-a-drifting-server.md).

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

```yaml
# .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:

```yaml
# .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/`](https://github.com/cheneeheng/mcp-cassette/tree/main/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](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/operations/OP-03-ci.md), [OP-06. Parallel test runs](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/operations/OP-06-parallel-test-runs.md), [OP-07. Pre-commit hooks](https://github.com/cheneeheng/mcp-cassette/blob/main/docs/guide/operations/OP-07-pre-commit.md).

## 9. Built with Claude Code

mcp-cassette was built with the help of [Claude Code](https://claude.com/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](https://github.com/cheneeheng/mcp-cassette/blob/main/LICENSE).
