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
└────────┘
snapshot --check fails the PR
that removes a tool, adds a required parameter, or changes a type, before your users' agents
break at 3am.check lints every model-facing text a
server publishes, not only tool descriptions: schema-level descriptions, prompts and their
arguments, and the names and descriptions of resources and resource templates. Sixteen rules for
the known shapes of tool-poisoning attacks (instruction overrides, concealment directives,
exfiltration URLs, invisible Unicode), each citing the OWASP MCP Top 10 risk it covers
and, for all but the length limit, the SAFE-MCP technique it implements, plus full JSON Schema
validation (draft-07 and 2020-12). That is prompt
injection with the payload in the tool description rather than the user's message: untrusted text
your agent reads as instructions and a user rarely sees at all.
Heuristics, not proofs: treat findings as review triggers, and pair with a dedicated security
scanner for depth.lint <cassette> reads the recorded answers to
tools/call, tasks/get, tasks/result,
resources/read and prompts/get, which is the only place that text still
exists. A fetched issue body saying "ignore all previous instructions" is invisible to any lint
that inspects a live server's listings.Works at the transport level: any server, any SDK, any language, any spec revision.
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.
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
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.
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.
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)
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
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)
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.
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.
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.
mcp-cassette