
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, which is where indirect prompt injection arrives.
npx mcp-cassette check --stdio "npx -y @modelcontextprotocol/server-everything stdio"
server: mcp-servers/[email protected] protocol: 2025-06-18
surface: 13 tools, 7 resources, 4 prompts
[OK] no findings
result: PASS (0 error(s), 0 warning(s), gate: error)
┌────────┐ record ┌──────────────┐ real ┌────────────┐
│ client │ ──────────► │ mcp-cassette │ ────────► │ MCP server │
└────────┘ │ (proxy) │ └────────────┘
└──────┬───────┘
▼
session.cassette.jsonl
│
┌────────┐ replay ▼
│ CI │ ◄────────── deterministic mock: the real server never runs
└────────┘
What it does, and the command that does it
Each block below is a command and the output it printed. Nothing here is a claim you cannot reproduce.
Your tests run offline, against the recording. check is an ordinary MCP client and cannot tell a cassette from a live server, which is the point: whatever your client is, it connects the same way. A client that reacts to change is covered too: the frames the recorded server sent on its own are replayed at their recorded position, and a subscriptions/listen is held open and fed at those positions.
mcp-cassette check --stdio "mcp-cassette replay session.cassette.jsonl"
server: [email protected] protocol: 2025-06-18
surface: 3 tools
[OK] no findings
result: PASS (0 error(s), 0 warning(s), gate: error)
A breaking contract change fails the build.
mcp-cassette snapshot --check --stdio "node dist/my-server.js"
[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)
A poisoned tool description is a finding, not a surprise. Prompt injection does not need a user to type it; it arrives in the tool description your agent was never going to show anyone. Sixteen rules, each citing the OWASP MCP Top 10 risk it covers and, for all but the length limit, the SAFE-MCP technique it implements.
mcp-cassette check --stdio "node dist/my-server.js" --format sarif --sarif-location mcp-contract.snapshot.json > mcp-cassette.sarif
Findings land in GitHub's Security tab, anchored to the line of the tool they describe.
A poisoned tool result is a finding too. A description is a promise made before the call; what the tool returned is data handed back after it, and it reaches the model with the same authority. A fetched issue body saying "ignore all previous instructions" is invisible to any lint that reads a live server's listings, and it is sitting in the cassette. lint reads it there. This one runs against a fixture in this repository:
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)
And the rest, each with the section that documents it.
What each command does not cover is written down too: see Where this stops and What a cassette is not.
Quickstart
npm install -g mcp-cassette # or: npx mcp-cassette ...
1. Health-check any server. The command at the top of this page, against any server you can start.
2. Record a session. Put the proxy between your client and the server:
# wherever your client config points at the server command, wrap it:
mcp-cassette record -o session.cassette.jsonl -- npx -y @modelcontextprotocol/server-github
3. Replay it offline. The cassette is the server now:
mcp-cassette check --stdio "mcp-cassette replay session.cassette.jsonl"
# → identical results, no network, no tokens, deterministic
4. Lock the contract
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
The failing output is at the top of this page. Every finding carries a stable rule ID in parentheses. Match on those, not on the wording: the prose can be reworded in a patch release, the IDs cannot.
CI in three lines
- uses: ivermin1123/[email protected]
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.
name: MCP contract
on: pull_request
permissions:
contents: read
pull-requests: write # the results comment
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22.x'
- run: npm ci && npm run build # your server must exist before it can be started
- uses: ivermin1123/[email protected]
with:
server-command: node dist/my-server.js
snapshot-file: mcp-contract.snapshot.json
mode: both # check | snapshot | both
fail-on: breaking # or: dangerous
lint-fail-on: error # or: warn | never
comment: 'true'
# Agent integration tests, offline, against a recorded cassette:
# point your MCP client at `mcp-cassette replay fixtures/session.cassette.jsonl`
- run: npx vitest run
lint-fail-on: never is the setting for adopting a rule set gradually. The
check still runs, every finding still reaches the job log and the pull-request
comment, and the comment names the gate it passed under. It is deliberately not
the same as dropping the lint with mode: snapshot, which removes the report
along with the gate.
What it waives is narrow, and the boundary is the point:
error is the default and is what every existing workflow already does, so
nothing changes for a consumer who does not set the input. warn moves the gate
the other way: it is stricter than the default, not looser.
A pull request from a fork gets a read-only GITHUB_TOKEN, so the comment is skipped there with a warning; the gate itself still runs and still blocks.
Which tag to pin
uses: resolves a git tag, not a version range, so the tag you name decides how much change arrives without you asking. Three of them are published, and they promise different things:
While the major version is 0, a minor release may carry a breaking change, and semver permits it and this project uses it. That is the whole difference between @v0 and a minor pin. Twice now a minor has done it: 0.3.0 added eight safety-lint rules, three at error, and 0.4.0 made snapshot --check walk nested schemas, which reports breaking changes that were previously hidden. Both reached @v0 users who had changed nothing on their side. A minor pin holds them back until you move it.
The two floating tags are force-moved onto each release commit, and only after npm and the GitHub Release have both succeeded, so neither can point at a version that failed to ship. Because they move, a checkout's local copy goes stale silently, and git ls-remote --tags origin is the only honest answer to "where does @v0.10 point right now".
Do not use @main. It is a development branch, not a release channel: its action.yml names the version being prepared, which may not be on npm yet, and a workflow pointed at it fails with ETARGET for reasons that have nothing to do with your server.
Prefer plain commands? They are the same gate:
- run: |
npx mcp-cassette check --stdio "node dist/my-server.js"
npx mcp-cassette snapshot --check --fail-on dangerous --stdio "node dist/my-server.js"
Testing with vitest
mcp-cassette/vitest puts a replay server around a describe block, so a suite that talks to an MCP server keeps working with no server and no network.
import { describe, expect, it } from "vitest";
import { useCassette } from "mcp-cassette/vitest";
const call = (id: number, name: string, args: unknown) =>
({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } });
const post = (url: string, body: unknown) =>
fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
describe("recorded call", () => {
const tape = useCassette("tests/cassettes/weather.http.jsonl");
it("is answered from the cassette", async () => {
const res = await post(tape.url, call(1, "echo", { m: "recorded" }));
expect(await res.json()).toMatchObject({ result: { content: [{ text: "recorded" }] } });
});
});
useCassette registers beforeAll / afterEach / afterAll itself, so call it at describe scope, not inside a test.
A miss fails the test that caused it. The replay engine answers a miss with a JSON-RPC error, which a test would happily swallow, so the adapter drains the misses after every test and throws:
it("fails with a mismatch when the arguments drifted", async () => {
await post(tape.url, call(2, "echo", { m: "drifted" }));
// No assertion here on purpose: afterEach is what fails this test.
});
CassetteMismatchError: mcp-cassette: no recorded answer for "tools/call", method and tool
match a recording, but arguments differ at: /m (recorded "recorded", got "drifted").
Re-record the cassette or adjust the interaction.
Two classes, because there are two fixes. CassetteMissError means nothing in the cassette answered; CassetteMismatchError means a recording matched the method and tool and diverged, and carries .changes, the actual diff, so an assertion can read it instead of the message. Both extend ReplayError. useCassette(file, { onMiss: "warn" }) leaves the JSON-RPC error frame as the only signal.
A field that changes every run is declared, not tolerated. volatile takes the same JSON Pointers the CLI's --volatile takes, relative to the request params and optionally scoped to one method, and they are added to whatever the cassette header declares:
const tape = useCassette("tests/cassettes/weather.http.jsonl", {
volatile: ["/arguments/requestedAt", "tools/call:/arguments/runId"],
});
A declared pointer is dropped from both sides before matching, so a call whose timestamp is new on every run still lands on its recording while every other argument is still matched exactly. A stdio cassette carries the declarations on the command it hands back, because the process your client spawns is the one that has to honor them.
HTTP and stdio are not symmetric, and this does not pretend otherwise. An HTTP cassette is served in-process, so its whole lifecycle is real. A stdio replay owns process.stdin and process.stdout and would fight vitest for them, so a stdio cassette hands back tape.command, the argv for your client to spawn:
const tape = useCassette("tests/cassettes/tools.jsonl"); // transport: stdio
// tape.command -> [node, <mcp-cassette cli>, "replay", <cassette>, ...volatile flags]
A process spawned by the client is a process the adapter does not own, so misses on that path arrive only as the JSON-RPC error the client receives: onMiss cannot fail the test for you, and there is nothing to drain. HTTP cassettes do not have this limitation.
vitest is an optional peer dependency, so it stays out of the dependency graph of anyone not using the adapter, and the CLI's three runtime dependencies are unchanged.
Testing with jest
mcp-cassette/jest is the same adapter for jest: the same useCassette, the same options, the same two error classes. Everything the section above says about misses, the per-test drain and the HTTP/stdio split is true here too, so this section covers only what differs, which is the setup.
The package is ESM, so jest needs its native ESM mode. A project still in CommonJS mode never reaches the adapter: jest fails every suite with Must use import to load ES Module: <your spec>. Switching is two settings and one flag, and nothing else:
// jest.config.mjs
export default {
testEnvironment: "node",
// Nothing to compile: the specs are plain ESM. Leave this key out and jest's
// default transform hands them to babel-jest, which applies whatever babel
// config the project has. An empty transform keeps that away from them.
transform: {},
};
{
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules jest"
}
}
Your specs are then plain ESM, importing jest's own globals so the adapter and the spec agree on which hooks they are registering:
import { describe, expect, it } from "@jest/globals";
import { useCassette } from "mcp-cassette/jest";
const call = (id, name, args) =>
({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } });
const post = (url, body) =>
fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
describe("recorded call", () => {
const tape = useCassette("tests/cassettes/weather.http.jsonl");
it("is answered from the cassette", async () => {
const res = await post(tape.url, call(1, "echo", { m: "recorded" }));
expect(await res.json()).toMatchObject({ result: { content: [{ text: "recorded" }] } });
});
});
That config, that flag and that spec are the ones under tests/fixtures/jest/, which this package runs as a real jest process on every test run. If the setup above ever stops working, the suite says so.
TypeScript specs need a transform on top, which is a choice about your toolchain rather than about this adapter, so the setup above is the part that is proven here.
One Node release cannot run jest in ESM mode at all. jest's ESM support is built on vm.SourceTextModule, and Node 24.6.0 regressed it: any ESM graph where two modules import the same third one fails to load with module is already linked. Every jest ESM suite hits it, not only ones using this adapter. Node 24.5.0 and 24.7.0 are both fine, as are Node 20 and 22, so the fix is to move off 24.6.0.
jest and @jest/globals are optional peer dependencies, like vitest, so they stay out of the dependency graph of anyone not using the adapter, and the CLI's three runtime dependencies are unchanged.
Commands
Recording a client that opens more than one connection
A client in the 2026-07-28 pinned negotiation mode (versionNegotiation: { mode: { pin } }) opens one connection for the server/discover probe and a second one for the session. Over stdio each connection spawns its own record, both pointed at the same -o path, so --mode once makes the second one refuse to start and --mode all makes it truncate the first. Either way the probe exchange is gone, and replaying the cassette fails the client's negotiation.
--mode append puts both connections in one cassette:
# configured once in the client, spawned once per connection
mcp-cassette record -o session.cassette.jsonl --mode append -- npx -y my-server
Replay then serves either connection out of that file: the probe connection gets its server/discover answer and the session connection gets its own exchanges, because a request is matched on its method and arguments and not on which connection recorded it.
Three things the mode does, and two caveats:
- The header is written once and never rewritten. A session that would contradict the header it cannot rewrite is refused before it writes anything, with a non-zero exit and the file exactly as it was: another transport, another server command, another
--redact-config, redaction switched on or off, or a handshake recorded into a cassette whose header declares the modern era. So a cassette that passedlintstill passes it afterwards. - One file, one timeline. Every appended entry carries its offset from the header's own
startedAt, so the connections stay in the order they happened. - Request ids are minted, not copied. Both connections start at id 1, and a reader pairs a response with its request by id, so the appended session's ids are re-keyed to
live-1,live-2and so on: the same sequencereplay --on-miss passthroughmints from, seeded past whatever the file already holds. The subscription id a 2026-07-28 frame carries is one of those ids, and is re-keyed with them. - Caveat: it appends across runs too. Nothing scopes an append to one run, so recording into a cassette from last week grows that file instead of replacing it. Use
--mode allwhen you want a fresh recording. - Caveat: the session is held in memory until it ends. The header cannot be rewritten, so nothing reaches the file before the last frame is in hand and the whole session goes out in one append at the end;
--mode onceand--mode allstream theirs as it happens.SIGTERMandSIGINTflush the block, aSIGKILLloses that session and leaves the file exactly as it was rather than half a line into it, and a long session's frames are held for as long as it runs. The official client SDK gives a probe connection's process one second afterSIGTERMbefore it sendsSIGKILL, so a server that takes longer than that to exit loses the probe pair.
The mode is for stdio recordings. record --http is a single proxy process that every connection of a run goes through, so one run already captures them all; it refuses --mode append rather than pretending to need it.
Contract drift tiers
breaking and minor are the obvious ends. The interesting tier is the middle one, borrowed from GraphQL-Inspector's trichotomy: changes that keep every existing call valid and still change what a caller observes.
dangerous is reported always and gated only with --fail-on dangerous, so upgrading does not turn anyone's CI red on its own.
The one reword that is not prose
A tool that is approved with an honest description and later serves one carrying an injection is the rug pull: SAFE-T1201, OWASP MCP03:2025. The snapshot is the only place it can be caught, because what makes the text an attack is that it is new relative to a file somebody read and approved. A safety lint run against the live server reports a poisoned description, but it cannot tell you the description was clean when you signed off on it.
So snapshot --check runs the CAS-L rules over the stored description and over the live one, and compares the two sets of rule ids rather than the two texts. Only the rules that name a SAFE-MCP technique count: CAS-L008, the 1500-character limit, measures a description instead of recognising one, so prose that merely got longer is advice and stays info.
A description that was already poisoned when it was committed has not pulled anything out from under anyone, and the lint reports it on every run; escalating it here would say the same thing twice. Only a rule the approved wording did not trip escalates, and the message names which ones:
[DANGEROUS] echo: description changed and the new wording trips CAS-L001, CAS-L003, which the approved wording did not (rug pull: SAFE-T1201, OWASP MCP03:2025) (tool-description-poisoned)
result: PASS (0 breaking, 1 dangerous, 0 minor, 0 info; gate: breaking)
The escalation replaces the info line rather than joining it, so one changed description is still one finding. tool-description-poisoned sits at dangerous, which means it is reported always and gates only under --fail-on dangerous, and it stays there: a reworded description that now declares command execution is worth a look, not a red build on the default gate, so failing on a rug pull is a choice you make with --fail-on dangerous.
Only the tool's top-level description is compared this way. The same text arriving in a parameter description, a schema title or default, or an annotation is reported by the structural rules exactly as before, and by the safety lint on every run.
Two rules exist to say "I do not know", and both count as breaking, because an unknown change to a contract is not evidence of safety:
They are separate ids on purpose: "we could not classify this" and "we could not follow this" are different problems, and a CI policy may reasonably treat them differently.
Where this stops
snapshot --check is finished, not in progress. It gates one server against one committed file and classifies the diff into the tiers above. That is the whole intended scope. Bugs in what exists still get fixed; no further rule families are planned.
Stated as a boundary rather than left for you to find by hitting it:
A change in the right-hand column is not missed silently. It surfaces as input-schema-changed-unclassified or input-schema-ref-unclassified, both of which count as breaking, so an unclassifiable change fails the gate instead of passing it.
The reason is worth stating plainly rather than leaving you to discover it: @kryptosai/mcp-observatory already covers this ground, and covers more of it. It is MIT, five months older, and its lock create / lock verify pair is the same one-command-against-one-committed-file workflow, with test and diff --fail-on-schema-drift alongside. Installed clean and run against three deliberately planted changes, it caught all three at sensible severities and gated correctly. If contract drift is your main problem, look there first. You will get more of it, maintained by people who are treating it as the product rather than as one command among several.
What remains true here: the tier vocabulary and stable rule ids above are a gate policy you can write CI rules against, and they sit next to record/replay and the safety lint in one binary. That is the reason to use this one, and it is a narrower reason than "it is the best contract differ".
The measurements behind this decision, including where they contradict claims made earlier in this project's own planning, are in docs/research/01-reality-check.md. One caveat from that document belongs here too: the comparison established that mcp-observatory does the same job, not that its output is better or worse. Nobody diffed the two tools' findings against each other.
Safety lint rules
Heuristics distilled from tool-poisoning research and the MCP security literature (SAFE-MCP, OWASP Agentic Top 10). They scan every model-facing text a server publishes: tool descriptions, schema-level descriptions, and the prompts, resources and resource templates it lists. A subset of them also reads what a recorded server returned.
Each rule cites the OWASP MCP Top 10 risk and the SAFE-MCP technique it implements, and both travel with the finding in --json.
The evidence column is the one to read first. It says whether text alone can tell an attack from a legitimate tool:
- shape: it can. The finding is the attack, and a legitimate tool produces none. A rule here that fires on ordinary Arabic or Chinese prose is broken, not strict.
- intent: it cannot. The finding is true (this tool really does describe running a shell command) but only you know whether that is meant to be there. A terminal server is not lying. These are always
warn, and they say what was declared instead of accusing.
They scan the tool's description and title, its annotations, and, because an attacker writes the whole schema rather than just its prose (SAFE-T1501, full-schema poisoning), every description, title, default, const, enum and examples string at any depth of the input schema.
A tool description is not the only text a server writes for a model, so the rules run over the rest of what it publishes:
A resource's name is read because it is display text: the specification has it stand in for title when none is given, and resources/read is keyed by uri, so the name is never what a client calls with. A prompt's name is not read, because it is what prompts/get is called with, and linting identifiers would report a server's own naming as an attack. A server that advertises resources and then fails resources/templates/list in any way, method-not-found included, is not reported either: having no templates is not a fault.
Four rules skip a name, because they read their subject as a sentence and a name is usually an identifier: CAS-L005 (sensitive local material) reports .env and credentials.json, CAS-L012 (command execution) reports shell and exec.ts, CAS-L007 reports a long generated identifier as an opaque blob, and CAS-L008 applies a length written for a description. All four keep running on the title and the description of the same resource, and on every other surface. Every other rule still reads a name. Nine of them look for something concealed (CAS-L001 through L004, L006, L009, L010, L013, L015), because what hides in display text reaches a reader exactly as it would from a description; the other three are intent rules (CAS-L011, L014, L016) whose patterns need a sentence that a name does not have. The measurement behind the split, over 63 listing entries from reference servers, is in BACKLOG.md. Each rule declares its own surfaces in src/lint-rules.ts, and the rules applied to a name and the output set are both derived from those declarations rather than listed separately.
Findings on the prompt and resource surfaces are reported at warn whatever level their rule carries, and graduate to the rule's own level in a later minor, no earlier than 2026-11-06: four weeks after 0.7.0, the release that pointed the rules at them. See the paragraph below: a rule pointed at a surface it did not scan before is held to the same discipline as a new rule. One pairing is capped at warn for good and does not graduate with the rest: CAS-L013 on a prompt (its title, description and argument text). It catches role and authority impersonation aimed at the model, and "Act as a system administrator and diagnose the issue" is what a prompt template is for, wherever in the template it is written. The cap is part of the rule's declaration, so the graduation cannot lift it by accident.
check fails on error-level findings only. check --fail-on warn opts into the stricter gate, the same way snapshot --fail-on works.
check --lint-fail-on <error|warn|never> moves the gate for these CAS-L rules alone, and is what the action's lint-fail-on input passes through. It defaults to whatever --fail-on is, so --fail-on warn still gates lint warnings exactly as it did before the flag existed. never reports every lint finding and gates on none; it does not reach the structural CAS-C checks (a duplicate tool name, an invalid inputSchema), which still fail, and every level still exits 2 when the server cannot be inspected at all.
New rules reach you at warn first, and so does an existing rule pointed at a surface it did not scan before: it ships at warn, and may graduate to error only in a later minor and no sooner than four weeks after the release that introduced it. So at the default gate, an upgrade that adds rules cannot turn an unchanged server red on the day it lands. At warn you asked for the stricter gate and a new rule does fail you on arrival, which is the trade you made.
Heuristics, not proofs: treat findings as review triggers, and pair with a dedicated security scanner for depth.
Every pattern in the rule set is proven free of super-linear backtracking by recheck in CI, because lint input is text an attacker wrote.
Linting what the server returned
The rules above read what a server declares. mcp-cassette lint <cassette> also reads what it returned, because a recording is the only place that text still exists.
This is the other half of prompt injection. A tool description is a promise made before the call; a tool result is data handed back after it, and it reaches the model with the same authority. A fetched issue whose body says "ignore all previous instructions", a document carrying zero-width characters, a page with a bidi override: none of it is visible to a lint that inspects a live server's listings, and all of it is in the cassette.
$ 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)
Every finding names the frame it came from, by request id and method, and the JSON path of the string that matched, so you can open the file at the exchange rather than search it. --json emits the same findings machine-readably. There is no --format sarif here: SARIF would have to invent a server, a tool count and a gate the run was decided against, and a cassette has none of those.
Six rules run on output. Each one declares that surface in the catalogue, so this table and the set that runs are the same thing:
The intent rules are excluded by their own premise: a result that mentions a shell is a result about a shell, not a tool declaring it runs one. Six of the twelve shape rules are excluded too, each because it fires on data a real server returns every day:
A rule that fires on ordinary data teaches everyone to ignore the lint, which costs more than the rule catches. These are judgements about running these patterns against this surface, not about the rules; the measurement behind them is in BACKLOG.md.
CAS-L006 is narrowed on output, the one rule whose threshold differs by surface. Here it reports a run of two or more invisible code points, or any Tags-block code point; the declaration-side rule still reports a single one. A lone zero-width character is what a web editor leaves in about three percent of real GitHub issue bodies, a file authored on Windows opens with a byte-order mark, and every ZWJ emoji is a U+200D by construction. Neither encoding the rule exists for can be spelled in one code point, so nothing it was written to catch is lost; the measurement is in BACKLOG.
Three further boundaries, stated rather than left to be discovered:
- Only
tools/call,tasks/get,tasks/result,resources/readandprompts/getanswers are read. Atools/listanswer is declarations, whichcheckalready lints; reporting it twice would teach a reader that one of the two can be ignored. The two task methods are there because a task-augmented call answers with a handle and delivers the tool's real output later, so that is where the text arrives. - A base64
blob, and thedataof an image or audio block, are carried but never decoded and never scanned. Decoding would mean running an attacker's bytes through an expansion this tool would then have to bound, and a lint that silently decodes is a lint nobody can predict. A poisoned blob is outside what this reports. - Text inside a redaction placeholder is not scanned. A
[REDACTED:...]span is this tool's own writing, so matching a rule against it would report mcp-cassette to you as the attacker. The text around it is still scanned.
Findings are reported at warn whatever level their rule carries, and lint exits on the header-versus-frames contradictions alone, exactly as it always has. Returned text is data: a third party wrote it, you did not, and a pipeline that was green yesterday does not go red because a server you depend on started quoting a GitHub issue.
SARIF output
check --format sarif emits SARIF 2.1.0, so findings land in GitHub's Security tab instead of a log nobody opens. The rule ids are the same CAS-Lxxx you see in the terminal, and each rule carries its OWASP and SAFE-MCP ids as tags:
{
"ruleId": "CAS-L001",
"level": "error",
"message": { "text": "instruction-override phrasing (classic prompt-injection) (in description): ..." },
"partialFingerprints": { "mcpCassetteFindingV1": "86f30c3c29712ff6" },
"locations": [
{
"physicalLocation": {
"artifactLocation": { "uri": "mcp-contract.snapshot.json" },
"region": { "startLine": 58 }
},
"logicalLocations": [{ "fullyQualifiedName": "get_weather", "kind": "member" }]
}
]
}
partialFingerprints is how code scanning recognises the same finding across runs, so it holds the rule and the subject and nothing that moves when the text is reworded. It also holds the subject kind, for a finding about a prompt, a resource or a resource template: a prompt and a tool may share a name, and one fingerprint for both would merge two unrelated alerts into one. A finding about a tool hashes exactly as it did before prompts were scanned, so alerts you have already triaged stay triaged.
Anchoring works the same way. A contract snapshot records tools, so a tool finding points at the line declaring that tool, and a prompt or resource finding points at the snapshot at line 1 rather than borrowing the line of a tool with the same name.
This is the wiring, copied from the job that runs it in this repository:
permissions:
contents: read
security-events: write # the only permission the upload needs
steps:
- name: Scan the poisoned fixture
continue-on-error: true # let the upload happen even when the gate fails
run: |
node dist/cli.js check \
--stdio "env TINY_EVIL=1 node tests/fixtures/tiny-server.mjs" \
--format sarif \
--sarif-location tests/fixtures/tiny-server-evil.snapshot.json > mcp-cassette.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: mcp-cassette.sarif
category: mcp-cassette # keeps these results in their own bucket
SARIF from the action
If you gate with the composite action rather than the CLI, set sarif-file and
upload what it writes. The action never uploads for you: that needs
security-events: write, and an action should not assume a permission on its
caller's behalf.
permissions:
contents: read
pull-requests: write # the results comment
security-events: write # the only permission the upload needs
steps:
- uses: ivermin1123/[email protected]
id: contract
continue-on-error: true # let the upload happen even when the gate fails
with:
server-command: node dist/my-server.js
sarif-file: mcp-cassette.sarif
- if: steps.contract.outputs.sarif-file != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ${{ steps.contract.outputs.sarif-file }}
category: mcp-cassette
# `continue-on-error` above kept the job green so the upload could run, so
# turn the gate back into a job result yourself.
- if: steps.contract.outcome == 'failure'
run: exit 1
Findings are anchored to snapshot-file when that file exists, and otherwise
by the CLI's own resolution below. The path the action wrote is exposed as the
sarif-file output, and that output stays empty when the input was unset or
when no document was produced, so the upload step is never handed a file that
is not a SARIF document. The if: on the upload step is what turns that empty
output into a skipped step: passed an empty sarif_file, upload-sarif fails
with Input required and not supplied rather than falling back to its default,
which would end the job at the upload with an error about the wrong thing.
Setting sarif-file starts your server a second time. One check run emits one
format, and both are wanted: the human-readable run is what the job log and the
pull-request comment show, the SARIF run is what code scanning ingests.
Findings are anchored to a file, and that file has to be a real one. check inspects a live server, so nothing it finds lives in a source file. GitHub code scanning nonetheless discards any result without a physicalLocation, so the anchor is the contract snapshot: your server's tool surface is recorded in that committed file, at a line, and it is where you go to read what your server advertises. mcp-cassette resolves one in this order, and never invents a path:
--sarif-location <file>, when you name it. A missing file, or one outside the working directory, is an error rather than a silent downgrade.mcp-contract.snapshot.json, when it exists. Each tool gets its own line.- the server script from
--stdio, when a token of it is a real file in the tree. Line 1, stated rather than guessed. - nothing, in which case the document is still emitted and still valid, and
checkwarns on stderr that code scanning will drop every result.
logicalLocations is kept alongside, so consumers that read logical locations are unaffected.
partialFingerprints are built from the rule and the tool, never the excerpt, so rewording a description does not resurrect a triaged alert as a new one. One rule firing on two fields of the same tool therefore collapses to a single alert.
The output is validated against the official OASIS SARIF 2.1.0 schema in the test suite; that schema is vendored under schemas/ (see schemas/vendored.json for source and version) so the suite stays offline, with a weekly canary warning if it drifts upstream.
Verified end to end against GitHub code scanning, not just against the schema. Run 31950265132 uploaded a document from the poisoned fixture; the analysis processed with no error and created six alerts, each one anchored to the line of the tool it describes (
get_weatherat line 58,brokenat line 30). The API record for that analysis readsresults=6, error="".It has also been verified in the failing direction, which is why the anchor exists. The same job on an earlier PR, emitting
logicalLocationsonly, uploaded successfully and then recordedresults=0withlocationFromSarifResult: expected a physical locationonce per finding, creating no alerts. An upload reporting success is not evidence that anything was kept.
That upload is not repeated on every pull request, and the reason is worth stating, because the snippet above is the right thing for your server and the wrong thing for this repository. The document above comes from a fixture that is poisoned on purpose, so uploading it per pull request would attach six alerts and a permanent red check to every branch this project ever opens; a check that is always red is one nobody reads by the second week, including the week it goes red for a real reason. Scanning a clean fixture instead would be worse, because an empty results array uploads perfectly happily, so the job would stay green through exactly the regression it exists to catch. The proof above was worth running once. What runs now is a split: ci.yml asserts on every pull request that each finding is anchored to a real file at a real line, without uploading anything, and a weekly non-blocking job in foundation-canary.yml does the real upload and asks the API what survived, parking its alerts on a ci/sarif-canary branch so they touch neither main nor any pull request. Both halves cover the action as well as the CLI: the pull-request half additionally asserts that the action passes snapshot-file through as the anchor, and the weekly half uploads the action's own document to the same parking branch in its own bucket.
HTTP header parameters (x-mcp-header)
The 2026-07-28 Streamable HTTP transport mirrors parts of the request body into HTTP headers, so that a load balancer, gateway or WAF can route and police MCP traffic without terminating TLS and parsing the body. Mcp-Method always carries the method, and Mcp-Name carries params.name or params.uri on a tools/call, a resources/read and a prompts/get. On top of those, a server may ask for one of a tool's own parameters to be mirrored, by putting an x-mcp-header key in that property's schema:
{
"name": "execute_sql",
"inputSchema": {
"type": "object",
"properties": {
"region": { "type": "string", "x-mcp-header": "Region" },
"query": { "type": "string" }
},
"required": ["region", "query"]
}
}
A call to that tool then carries Mcp-Param-Region: us-west1 beside the body that says the same thing, and the gateway routes on the header while the server executes on the body. Supporting this is required of a client, not optional, so check, snapshot and verify over --url do it: they read the declarations out of tools/list and send the headers on every later tools/call. verify lists the tools for exactly this reason when the cassette calls one. Nothing of the kind happens on stdio, which has no headers, or in the legacy era over HTTP, which never defined them.
A value is encoded, not pasted. A plain ASCII value with no leading or trailing whitespace goes as itself; anything else, and any value that would be mistaken for the marker, goes as =?base64?<base64 of the UTF-8>?=. That covers non-ASCII text, control characters, and the leading or trailing spaces an HTTP parser would otherwise trim away. A null or absent argument sends no header at all.
A malformed declaration costs its tool, and who pays depends on what the command is for. The spec makes a calling client exclude such a tool from tools/list rather than ignore the annotation, so that one bad declaration cannot make a server's other tools unusable. verify does that: it withholds the tool from its header resolution, says so on stderr, and re-fires the recorded call without Mcp-Param-*. check and snapshot read the listing instead of calling from it, so they keep the tool and check reports the declaration as CAS-C008 at warn. An audit that dropped the tool would let a server hide it from the safety lint with a one-key edit, and would call a tool the server still advertises removed. These are the constraints, each of them a reason:
Nested properties are fine as long as every step is a properties key: an annotation on /target/cluster reads that value and sends it.
Cassette format (open, v2)
Append-only JSONL. Line 1 is a header; each following line is one captured frame with direction and a millisecond offset:
{"type":"header","cassetteVersion":2,"recorder":"[email protected]","startedAt":"...","transport":"stdio","command":["npx","-y","..."],"redaction":{"applied":true}}
{"type":"frame","t":12,"dir":"c2s","frame":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}}
{"type":"frame","t":38,"dir":"s2c","frame":{"jsonrpc":"2.0","id":1,"result":{...}}}
Non-JSON-RPC lines (servers that log to stdout) are preserved as {"type":"raw",...}, so a cassette is a faithful transcript even of misbehaving servers. Interactions appended later by replay --on-miss passthrough carry "origin":"live". The format is stable and documented so other tools can consume it. v1 cassettes read forever; v2 adds optional fields for lifecycle eras, HTTP transport, and streamed (SSE) responses. See docs/cassette-format-v2.md. Files from a newer format version are refused with a clear error rather than half-read.
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 placeholder:
[REDACTED:<rule>:<hash8>] e.g. [REDACTED:github:3f9a1c07]
hash8 is the first 8 hex characters of the SHA-256 of the secret. It is deterministic, and that's 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. Two different secrets stay distinguishable; the same secret is recognizable across recordings.
Lines the recorder cannot parse as a JSON-RPC frame (a batch array, a frame missing "jsonrpc":"2.0", a server that logs to stdout) are stored as raw entries and redacted too. If such a line is itself JSON it gets the full key-context walk, with only the secret substrings replaced so the rest of the line keeps its exact bytes. If it is not JSON at all, only the shape rules apply: there are no keys, so there is no key context to use.
What gets over-redacted, and why we err this way
The key list is matched as a substring and deliberately not anchored, so it still catches accessToken and apiToken alongside access_token. The cost is collateral: a field like password_policy or secretariat is redacted because its name contains a sensitive word, even though its value is harmless. That is the trade we want: anchoring the match would swap a visible false positive for a silent missed secret, and a redaction placeholder where you expected prose is obvious, while a leaked credential is not. When a specific value is caught and should not be, allow in a --redact-config file exempts that value by itself, which is the narrow fix; --no-redact is the one that turns everything off.
The one exception is OAuth/OIDC discovery metadata (RFC 8414), where token_endpoint and authorization_endpoint hold public URLs that servers legitimately return. Those keep their value when it is a plain absolute http(s) URL with no query string and no user:password. A discovery field holding anything else (a token, a URL with a query string, a URL with credentials in it) is still redacted.
Your own rules
The built-in rules know the shapes everyone shares. A credential in a house format, or a field name only your server uses, is invisible to them, and --no-redact is not an answer to that. --redact-config <file> adds your own:
{
"patterns": [{ "name": "acme", "regex": "ACME-[A-Z0-9-]{10,}" }],
"keys": ["handle"],
"allow": ["sk-THIS-ONE-IS-PUBLIC-000000"]
}
mcp-cassette record -o session.cassette.jsonl --redact-config redaction.json -- npx -y my-server
mcp-cassette replay session.cassette.jsonl --redact-config redaction.json
patterns adds token shapes, and name becomes the rule label inside the placeholder, so it is lowercase letters only and may not be one of the built-in names: a cassette reader seeing [REDACTED:bearer:...] is entitled to conclude the bearer rule put it there. Your patterns run before the built-ins, so a house format wins over a generic shape that would only partly match it, and each is compiled global whatever you wrote, because a value that occurs twice has leaked twice. Only the i and u flags are accepted. keys adds names whose string values are secrets whatever their shape, matched against the whole key or any one of its segments, so handle covers handle, session_handle and sessionHandle but not handler. allow lists values the rules must leave alone, which is the precise answer to the over-redaction described above. Each is compared exactly against what a rule would replace: the token after Bearer, the password inside a URL, the whole match for every other pattern, and the whole value under a sensitive key. It is not a substring test, so an allowed value embedded in a longer secret is still redacted. A pattern must also not be able to match inside [REDACTED:name:hash]; one that can is ignored there rather than allowed to redact the placeholder, so a second redact pass still produces the first pass's file.
The same file has to go to record and to replay. Redaction runs before fingerprinting, so a rule that ran at record time and not at replay time leaves the two sides hashing different text, and every request misses for a reason the diff cannot explain. A recording made with a config carries a hash of it in the header, and replay refuses a cassette whose hash does not match the config it was given, saying which side is short one; redact refuses to rewrite such a cassette under any other config for the same reason. The hash is all that is stored, because a regex describes the secrets it catches and an allowed value is a value, but it is not a secret itself: like the placeholder hash below, it is an unsalted SHA-256 that anyone with a candidate config can confirm offline. Put only already-public values in allow.
An unchecked pattern is a liability. Your patterns run over every string a server answers with, which on a hostile server is attacker-controlled text, and a pattern that backtracks catastrophically turns recording into a denial of service against your own CI. Reading the regex does not tell you whether it does:
npm install --no-save recheck # optional peer, not a runtime dependency
mcp-cassette redact --redact-config redaction.json --check-config
It prints one line per pattern and exits 1 if any is not proven linear-time. Without recheck installed it exits 2 and says so, rather than reporting a pass nobody computed. The built-in rules are held to the same standard by scripts/recheck-rules.mjs on every CI run.
Working with existing cassettes
mcp-cassette redact session.cassette.jsonl -o session.redacted.jsonl # clean a recording
mcp-cassette redact session.cassette.jsonl --scan # audit only, exit 1 if anything is found
--scan writes nothing and prints one line per hit (rule, direction, method, path, masked excerpt), so it drops straight into CI as a tripwire on committed fixtures:
[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)
Redaction is idempotent (running it over an already-redacted cassette is a no-op) so re-recording and re-cleaning are both safe.
Turning it off
mcp-cassette record --no-redact -o session.cassette.jsonl -- npx -y my-server
The header records which way it went ("redaction":{"applied":false}), and replay reads that flag to decide whether to redact incoming requests. Don't commit an unredacted cassette.
The caveat
The hash is not a security boundary. It is an unsalted, truncated SHA-256 of the plaintext, sitting in a file you are about to commit. For a high-entropy API token that reveals nothing useful, but for a short or human-chosen value it is a verification oracle: anyone with a candidate guess can confirm it offline. Redaction removes the secret; it does not make a weak secret safe to have referenced. Rotate anything a cassette ever touched.
This is pattern matching, and pattern matching cannot catch every secret. A credential with no recognizable prefix, under a field name nobody would call a token, goes through untouched: a session cookie in params.state, a signed URL, a customer record, a private key pasted into a prompt. Redaction lowers the odds of an accident; it is not a guarantee, and it is not a substitute for reviewing a cassette before you commit it or for running a real secret scanner over your repo. Treat --scan as a tripwire, not a clearance.
How this relates to other tools
-
@modelcontextprotocol/conformance: the official spec-conformance suite. Use it to verify you implement the protocol correctly; use mcp-cassette to test your server's behavior and contract. The two are complementary, and we intend to contribute scenarios upstream. -
MCP Inspector / MCPJam: interactive debugging. The Inspector also has a CLI client for scripts and CI: one request per run, JSON output, stable exit codes. It does not record a session or serve one back, diff a contract, or lint descriptions; if a single scripted request is all your pipeline needs, it is already installed.
-
@kryptosai/mcp-observatory: a security and trust platform, and the nearest neighbour this project has. It covers much more ground: it reads the MCP configs of ten clients and audits every server it finds, it has audit profiles, attack simulation, cross-server toxic-flow analysis, static source review and package-name checks, it enforces at runtime through signed trust receipts and a wrapping proxy, and it scores, badges and tracks a fleet over time. If what you want is a security posture for the servers you run, start there. Where it overlaps this tool on contract drift, look there first; Where this stops says why, with the measurement behind it.It has
recordandreplaycommands too, and they are a different thing. Both are marked hidden, and its replay runs observatory's own checks offline instead of serving the cassette to a client, so a test suite cannot point its MCP client a