mcp-cassette

The cassette is itself an MCP server, so any client in any language connects to it exactly as it connects to the live one: no library to import, no product code to change, no transport to wrap.

Record one session against a real Model Context Protocol server, then run your agent tests against the recording: no credentials, no rate limits, no network. The replay is a server, not a stub: it hands back the notifications the recorded server pushed on its own, at the position the recording put them, and it serves a whole io.modelcontextprotocol/tasks poll sequence. One file covers both protocol eras, the classic lifecycle and 2026-07-28. The same binary gates your tool contract against breaking changes, lints every text a server publishes to the model for poisoning, and lints what a recorded server handed back.

mcp-cassette is on npm. npm install -g mcp-cassette, or run any command below without installing, via npx mcp-cassette .... Published with build provenance from the tagged release workflow.

┌────────┐   record    ┌──────────────┐   real    ┌────────────┐
│ client │ ──────────► │ mcp-cassette │ ────────► │ MCP server │
└────────┘             │    (proxy)   │           └────────────┘
                       └──────┬───────┘
                              ▼
                    session.cassette.jsonl
                              │
┌────────┐   replay           ▼
│   CI   │ ◄────────── deterministic mock: the real server never runs
└────────┘

Why it exists

Works at the transport level: any server, any SDK, any language, any spec revision.

The seven commands

check

Health-check any live server: lifecycle handshake, tool/resource/prompt listing, JSON Schema validation, and the safety lint. Exits non-zero on errors.

mcp-cassette check --stdio "npx -y @modelcontextprotocol/server-everything stdio"
server: mcp-servers/everything@2.0.0  protocol: 2025-06-18
surface: 13 tools, 7 resources, 4 prompts
[OK] no findings
result: PASS (0 error(s), 0 warning(s), gate: error)

Two gates, so the safety lint can move without moving the structural checks: --fail-on error|warn covers every finding, and --lint-fail-on error|warn|never covers the CAS-L lint alone, defaulting to whatever --fail-on is. --format sarif writes the findings for GitHub's Security tab.

record

A transparent stdio proxy. Put it between your client and the server; it captures every JSON-RPC frame in both directions into an open JSONL cassette. Bytes are forwarded verbatim, so recording is invisible to both sides, and secrets are redacted before they hit the file.

# wherever your client config points at the server command, wrap it:
mcp-cassette record -o session.cassette.jsonl -- npx -y @modelcontextprotocol/server-github

replay

Serves the cassette as a stdio MCP server, or over Streamable HTTP with --listen. The cassette is the server now. Requests match on method plus arguments, repeated identical calls replay in recorded order, and anything unrecorded gets a clear JSON-RPC error. A field that moves every run (a timestamp, a generated id) is declared rather than tolerated: --volatile /arguments/requestedAt, or a volatile list the cassette carries itself.

mcp-cassette check --stdio "mcp-cassette replay session.cassette.jsonl"
# → identical results, no network, no tokens, deterministic

It is a server and not a lookup table. The frames the recorded server sent on its own are replayed at the position the recording put them, each anchored to the request whose answer preceded it, so a notifications/tools/list_changed reaches the client after the call that changed the list and not at connect. A 2026-07-28 subscriptions/listen is held open and fed at those anchors. A session that used the io.modelcontextprotocol/tasks extension replays whole: every tasks/get for one task comes out of one pool in recorded order, whatever interval the client polls at, and a terminal answer is served again for as long as the client asks. How replay works is the long version.

snapshot

Locks the contract: tools, schemas, and annotations in a canonical snapshot. --check classifies drift into four tiers, breaking, dangerous, minor and info, each finding carrying a stable rule id, and exits non-zero on breaking. Match your CI policy on the ids in parentheses, not on the wording.

mcp-cassette snapshot --stdio "npx -y my-server"            # writes mcp-contract.snapshot.json
mcp-cassette snapshot --check --stdio "npx -y my-server"    # CI: fails on breaking changes
[BREAKING] slugify: tool removed (tool-removed)
[BREAKING] add: parameter "precision" is now required (input-property-became-required)
[DANGEROUS] add: parameter "mode" added (input-property-added-optional)

result: FAIL (2 breaking, 1 dangerous, 0 minor, 0 info; gate: breaking)

One drift is not about shape at all. A tool approved with an honest description and later serving one that trips a safety rule the approved wording did not is the rug pull, and the snapshot is the only place it can be caught: what makes the text an attack is that it is new relative to a file somebody read and signed off. That is tool-description-poisoned at dangerous, not an info line about prose.

lint

Reads a finished cassette: its header against its own frames, and what the recorded server returned. --json emits the same findings machine-readably.

mcp-cassette lint tests/fixtures/poisoned-output.cassette.jsonl
[WARN] CAS-L001 tools/call #3 /result/content/0/text: instruction-override phrasing (classic prompt-injection) (in recorded output)
       evidence: "fails on Windows. Ignore all previous instructions and ope"
[WARN] CAS-L006 resources/read #4 /result/contents/0/text: invisible/steganographic Unicode (in recorded output)
       evidence: "contains U+200B (5 invisible code points in a row)"
tests/fixtures/poisoned-output.cassette.jsonl: header and frames agree
tests/fixtures/poisoned-output.cassette.jsonl: 2 warning(s) (reported, not gated)

verify

The opposite direction. A replay answers from the recording, so it cannot tell you whether the live server still behaves that way. verify re-fires the recorded requests at the real thing and diffs the answers, which is how you find out a cassette has gone stale.

mcp-cassette verify session.cassette.jsonl -- npx -y my-server

redact

Cleans a cassette you already have, or audits one without touching it. --scan writes nothing and exits non-zero if it finds anything, so it drops into CI as a tripwire on committed fixtures. New recordings are redacted by default; this is for the ones that came before.

mcp-cassette redact session.cassette.jsonl -o session.redacted.jsonl   # clean a recording
mcp-cassette redact session.cassette.jsonl --scan                      # audit only, exit 1 on a hit
[keyctx] c2s tools/call params.arguments.token: ghp_**************** (39 chars)
[github] s2c tools/call result.content[0].text: ghp_**************** (39 chars)
result: FOUND (2 secret(s) detected)

CI recipe

There is a composite action that runs both gates, so three lines is the whole of it:

- uses: ivermin1123/mcp-cassette@v0.10
  with:
    server-command: node dist/my-server.js

That runs the safety check and the contract gate against the snapshot you committed, then leaves one comment on the pull request with the classified diff, updated in place on every push rather than appended. lint-fail-on moves the gate for the safety lint alone, and sarif-file writes a document for a later upload-sarif step so findings land in the Security tab instead of a log nobody opens.

Nothing here is Actions-specific, and the CLI is the same two commands:

- name: MCP contract & safety checks
  run: |
    npx mcp-cassette check    --stdio "node dist/my-server.js"
    npx mcp-cassette snapshot --check --stdio "node dist/my-server.js"

- name: Agent integration tests (offline, via cassette)
  run: npx vitest run   # your tests point the MCP client at:
                        # mcp-cassette replay fixtures/session.cassette.jsonl

Both runners have an adapter, mcp-cassette/vitest and mcp-cassette/jest: one useCassette() call wraps a describe block with a replay server, and a fingerprint miss fails the test that caused it instead of arriving as a JSON-RPC error the assertion never looks at. Same call, same options, same two error classes on either runner. See Testing with vitest and Testing with jest.

Cassettes are plain files

Append-only JSONL: a header line, then one line per captured frame with direction and a millisecond offset. Non-JSON-RPC output from chatty servers is preserved too, so a cassette stays a faithful transcript even of a misbehaving server. The format is open, versioned, and documented so other tools can consume it.

Secrets redaction

A cassette is only useful if you can commit it, and you can only commit it if it has no credentials in it. record redacts by default. Every string in every captured frame, plus the server command in the header where tokens often arrive as CLI flags, is scanned on the way to disk. The bytes forwarded to your client and to the real server are untouched, so the live session behaves exactly as if the proxy weren't there.

Each hit becomes a deterministic placeholder, and that determinism is what keeps replay working: when your test sends the live token, replay redacts the incoming request the same way before matching, so it collapses to the same placeholder that was recorded and hits the same response.

[REDACTED:<rule>:<hash8>]        e.g. [REDACTED:github:3f9a1c07]

Rules cover bearer tokens, JWTs, URL credentials in connection strings, GitHub / OpenAI / Anthropic / Slack / AWS / Google key shapes, and any JSON value under a key that looks like a credential. There is also a redact command to clean or audit a cassette you already recorded. --scan writes nothing and exits non-zero if it finds anything.

Your own secret shapes go in a config: --redact-config <file> on record, redact and replay adds patterns, key names whose values are secrets whatever their shape, and allowed values the built-in rules over-redact. The same file has to reach record and replay, because redaction runs before fingerprinting: a cassette carries a hash of the config it was recorded under, and replay refuses one whose hash does not match rather than missing every request. Custom patterns run over text an attacker wrote, so redact --check-config, with the optional recheck peer installed, refuses a pattern it cannot prove linear-time before you ship it.

Redaction is pattern-based, so treat it as a strong default rather than a guarantee: a credential in a shape no rule knows can still reach the file. Review a cassette before you commit it.

Links