snapgaugesnapgauge

Docs

Everything below is grounded in the committed spec (docs/SPEC.md) and the rule registry (docs/RULES.md), not restated from memory.

Install

npx snapgauge@1 init --url https://mcp.example.com/mcp   # or --command / --fixture
npx snapgauge@1 record                                    # writes .snapgauge/<target>.snapshot.json
npx snapgauge@1 check                                      # probe live -> diff vs stored -> gate -> exit code

As a GitHub Action, against a snapshot already committed to your repo:

- uses: jamessuuu/snapgauge@v1
  with:
    config: snapgauge.config.json   # default
    fail-on: risky                  # default (SPEC §5)

Five-minute quickstart

The fastest way to see the engine work with nothing installed: /demo runs the real engine against fixture servers bundled into the page, entirely offline, in a Web Worker.

To run it on your own machine against a real MCP server, clone the repo — the CLI is not yet published (see the README status banner) — and record twice, then diff:

git clone https://github.com/jamessuuu/snapgauge
cd snapgauge && pnpm install && pnpm --filter snapgauge build

# record a baseline against a bundled fixture server (no MCP server of your own required)
node packages/snapgauge/dist/cli/index.js record demo --fixture clean@v1 --dir /tmp/snapgauge-demo

# record a "next release" of the same server — one tool removed, one new required argument
node packages/snapgauge/dist/cli/index.js record demo-v2 --fixture drift-breaking@v2 --dir /tmp/snapgauge-demo

# diff them — offline, no network
node packages/snapgauge/dist/cli/index.js diff /tmp/snapgauge-demo/demo.snapshot.json /tmp/snapgauge-demo/demo-v2.snapshot.json

This is the actual, verified output of that last command:

breaking   tool.input.required.added          tools.get_weather.inputSchema.required.date — input "date" is now required — a client recorded against the old contract does not send it
breaking   tool.removed                       tools.archive_note — tool "archive_note" was removed — a client holding the old contract will fail
risky      tool.description.changed           tools.get_weather.description — description changed — the trigger surface a model routes on (SPEC §5: risky, not cosmetic)
compatible tool.input.optional.added          tools.list_notes.inputSchema.properties.cursor — optional input "cursor" was added
cosmetic   serverInfo.version.changed         discover.serverInfo.version — serverInfo.version changed
cosmetic   tool.icons.changed                 tools.get_weather.icons — icons changed
6 findings (2 breaking, 1 risky, 1 compatible, 2 cosmetic); gate fail-on=risky -> DRIFT (exit 1)

Exit code 1: two findings (breaking and risky) are at or above the default gate. Fixing this means one of two things — the consumer decides the drift was intentional and runs check --update to accept the new snapshot, or the server owner reverts the change. See failure modes below for the full exit-code table.

Once published, replace the fixture shortcut above with a real target: npx snapgauge@1 init --url https://your-server/mcp writes snapgauge.config.json, then record and check run the identical pipeline against it.

Snapshot format

One file per target, committed to your repo: .snapgauge/<target>.snapshot.json. It records discover (supported versions, capabilities, serverInfo, instructions, cache hints), every tool (schema, annotations, icons, x-mcp-header bindings), resources, prompts, the observed behavior of any probes you declared, and the T-group transport assertion results.

Shape, not values, by default. A probed call's response is stored as a recursive type sketch — keys sorted, array element types unioned — not the actual values. Text content is stored as { type: "text", sha256 }. This is deliberate: shape-capture is what makes a snapshot safe to commit (it cannot leak a real customer's data out of a tool response) and stable against servers that return live data — weather, timestamps, ids. capture: "values" is opt-in per probe, for fixtures and for tools you know are deterministic.

Canonicalization is part of the format: keys sorted lexicographically, 2-space indent, a trailing newline — chosen for git-diff legibility over byte-canonical JCS (hashes are computed separately, over JCS bytes, for comparison). A snapshot is only ever comparable to itself: probeSpecHash (a hash of the target's declared probes) must match between two snapshots being diffed, or the run exits 4 rather than producing a silent partial comparison.

Diff taxonomy

Every difference between two snapshots is classified into exactly one of four tiers, direction-aware (old → new). The default gate is fail-on: risky — breaking and risky findings fail CI; compatible and cosmetic ones do not.

Descriptions and titles are risky, not cosmetic. Most diff tools would call a text rewrite cosmetic. This one does not: a tool's description and title are the trigger surface a model routes on when deciding which tool to call — rewrite the wording and an agent's behavior can change even though the schema did not move a single byte.

breaking (13)

rule idwhat it means
annotation.destructiveHint.raiseddestructiveHint became true — the tool now declares it destroys data.
annotation.idempotentHint.revokedidempotentHint was true and no longer is — retry logic built on it is now unsafe.
annotation.readOnlyHint.revokedreadOnlyHint was true and no longer is — clients treating the tool as safe now mutate.
capability.removedA server capability disappeared from discover — features clients negotiated are gone.
error.code.changedA probed call's error code changed — client error handling keyed on the code breaks.
tool.input.enum.removedEnum value(s) removed from an input — clients sending them are now rejected.
tool.input.required.addedAn input became required — clients recorded against the old contract do not send it.
tool.input.type.narrowedAn input's accepted value space shrank (type set reduced/changed, or an enum constraint introduced) — previously-valid calls are now rejected.
tool.output.required.addedAn output field became required (SPEC §5 places this at breaking).
tool.removedA tool present in the old contract is gone — clients holding the old contract fail.
version.droppedA previously-advertised protocol version is no longer supported.
xhdr.addedAn x-mcp-header binding appeared on an existing tool — a client on a cached tools/list will now be rejected with -32020.
xhdr.changedAn x-mcp-header binding's header name changed — clients still send the old header and are rejected with -32020.

risky (12)

rule idwhat it means
annotation.destructiveHint.relaxeddestructiveHint was true and no longer is (annotation.*.relaxed family) — the tool now claims to be safer.
annotation.idempotentHint.relaxedidempotentHint became true (annotation.*.relaxed family) — clients may now retry freely on its word.
annotation.readOnlyHint.relaxedreadOnlyHint became true (annotation.*.relaxed family) — an increased safety claim clients may act on.
cacheScope.widenedcacheScope went private → public — responses may now be shared across authorization contexts.
instructions.changedServer instructions changed — system-prompt-adjacent steering text.
order.changedtools/list order changed (same tool set, both sides stable) — churns prompt caches keyed on list order.
order.nondeterministicRepeated tools/list calls disagree on order (3-repeat probe) — reported as instability, NEVER as a false breaking diff.
output.enum.addedEnum value(s) added to an output — consumers may not handle the new values.
tool.addedA new tool appeared — expands the agent's action surface (shadowing risk).
tool.description.changedDescription text changed — the trigger surface a model routes on (never cosmetic).
tool.title.changedTitle text changed — also routing surface.
ttlMs.raisedttlMs increased — clients will serve stale surface data for longer.

compatible (8)

rule idwhat it means
cacheScope.narrowedcacheScope went public → private — strictly safer sharing semantics.
capability.addedA new server capability appeared in discover.
required.removedAn input is no longer required — clients that send it are still fine.
tool.input.enum.addedEnum value(s) added to an input — everything the old contract sent still works.
tool.input.optional.addedA new optional input appeared — old calls remain valid.
tool.input.type.widenedAn input's accepted value space grew (type set expanded, or an enum constraint dropped) — old calls remain valid.
ttlMs.loweredttlMs decreased — clients refresh sooner.
version.addedA new protocol version is now advertised.

cosmetic (4)

rule idwhat it means
_meta.vendor.changedA tool's vendor _meta changed — vendor-prefixed keys carry no contract.
serverInfo.version.changedserverInfo.version changed — releases are expected to happen.
text.whitespace-onlyA description/title/instructions change that is whitespace-only — the one text change that IS cosmetic.
tool.icons.changedTool icons changed.

Compat + degradation checks

The official conformance suite answers whether a server obeys the 2026-07-28 revision right now. It does not check whether the server still works with a client one version back, whether it degrades correctly when a client declares fewer capabilities, or whether it is honest about which protocol versions it actually supports. That surface is what the compat engine covers.

A profile is (protocol version, client capabilities, extensions, header behavior). Built-ins include modern-full, modern-minimal (no extensions declared — the critical one), and legacy/no-capability variants. Every declared probe is replayed under every profile, and the response is scored per (tool × profile): ok | declined-correctly | degraded-reported | degraded-silent | violation. Any violation exits 3 — the server is wrong, not merely different, which is a different owner and a different fix than exit 1.

rule idclasswhat it means
compat.erainfomodern-only | dual | legacy, from a modern-then-initialize probe.
compat.version_advertised_unsupportedviolationA version listed in discover.supportedVersions fails a plain tools/list — the server is lying about support.
compat.set_varies_per_connectionviolationSame profile, two fresh connections, different tool set (MUST NOT).
degrade.wrong_errorviolationUnder a reduced profile a call must succeed or fail with -32021 naming exactly the missing capabilities. A 500, a generic error, a hang, or a text blob is a violation.
degrade.over_declaredviolation-32021 names a capability the tool never actually exercises under modern-full — gating at request entry instead of at use.
degrade.silentriskyA complete result under a reduced profile that differs in shape from modern-full with no signal at all. Permitted by the spec — reported, not failed. This is what the board exists to publish.
degrade.reportedinfoThe good citizen: the result degrades under the reduced profile AND says so via the degradation marker.
xhdr.not_statically_reachableviolationAn x-mcp-header binding sits behind items/oneOf/anyOf/allOf/$ref instead of a plain properties chain — invisible to conforming clients.

Full T-group (transport framing) and X-group (x-mcp-header validity) catalogs — 20 and 9 rule ids respectively — are in docs/RULES.md.

The board

/board publishes a refreshed, public read of 8–12 named MCP servers: era (modern-only / dual / legacy), advertised protocol versions, the T-group framing assertions, cache-hint presence, x-mcp-header validity, and the D-group checks reachable without tools/call. Unauthenticated and read-only — a server that requires auth is a row marked not tested (auth required), never guessed. A GitHub Actions job refreshes it weekly and commits boards/<date>.json; the commit history is the durable, un-fakeable record.

Disclosure policy. For any MUST-level violation, an upstream issue is filed first and the row records reportedAt; publication follows at least 7 days later (publishedAt). Findings are stated as neutral observations — "returns 200 on GET; the revision says 405" — never as scores, grades, or security claims.

boards/roster.json ships empty on purpose: which public servers belong on the board is a decision, not a default, so the page shows the honest empty state until that decision is made.

CI integration

action.yml is a composite GitHub Action. It runs snapgauge check --json against your committed config and snapshot, then reuses snapgauge report — the same pure reformatter the CLI itself uses — to turn the saved result into GitHub Actions annotations (::error:: / ::warning:: per finding) and a job-summary markdown table. No second live check runs to build the summary.

- uses: jamessuuu/snapgauge@v1
  with:
    config: snapgauge.config.json   # default
    fail-on: risky                  # default
    # target: my-server             # only needed when the config has more than one

Output: exit-code, the same value snapgauge check would exit with locally. A finding's location is a JSON path into the target's schema (e.g. tools.get_weather.description), never a source file position — the action does not fabricate one.

Failure modes

Exit codes:

codemeaningwho fixes it
0Clean — no findings at/above the gate.Nobody — nothing to do.
1Drift at/above the gate (--fail-on, default risky).The consumer: review the diff, then either --update the stored snapshot (drift was intentional) or fix/pin the server.
2Probe/connection failure (unreachable, auth, timeout, malformed response).The consumer's environment/config — check the target URL, credentials, and network reachability.
3Compat/degradation violation — the server is wrong, not merely different.The server's maintainer — it violates the 2026-07-28 revision's degradation contract.
4Usage/config/snapshot-format error (incl. probe-spec mismatch: re-record).The consumer — fix the CLI invocation or config, or re-run snapgauge record.
5Internal error — a bug in snapgauge itself.snapgauge's maintainer — please report it (SECURITY.md).

The ugly paths, by situation:

situationcontract
Target unreachable, TLS failure, or timeoutExit 2. No snapshot written, no partial diff. check never writes a snapshot.
Target returns 5xx mid-probeThat probe is marked error; remaining probes still run. The result carries incomplete: true — missing evidence is never treated as "no change" (exit 2).
Server is non-deterministic (list order or values churn)3 repeats per list; disagreement becomes order.nondeterministic (risky) instead of a false breaking. Value churn is why shape-capture is the default.
probeSpecHash changed since the stored snapshotExit 4 — "config changed; re-record." Never a silent partial comparison.
Stored snapshot's formatVersion is newer than this binaryExit 4 with the required version. An older formatVersion migrates only with --migrate.
Auth token missing or expiredExit 2, classified auth. The token is never written to the snapshot or the log.
Redirect on the MCP endpointNot followed. Recorded as a transport.redirect finding, not a hop.
Response over 256 KB, or a snapshot over 1 MBTruncated, marked truncated: true — that probe cannot produce a breaking finding (evidence is incomplete).
stdio server hangs or dies10s per-request timeout, SIGKILL on teardown, exit 2. No orphan processes.
Hosted /live: target refused by the SSRF policy400 TARGET_NOT_ALLOWED with the reason class only — never the resolved address.
Hosted /live: rate limit or cost cap hit429, or the service is paused with a banner — /demo always still works (offline, zero caps, same engine).
Board job fails or GitHub auto-disables it (60-day rule)/board shows a dead-man banner once the newest boards/*.json is more than 10 days old, dated. Never presented as current.

Limitations

  • Auth-gated tools: the hosted /live check never accepts a bearer token, so it can only audit a server's unauthenticated discovery surface.
  • Per-tenant tool sets: if a server returns a different tools/list per API key, snapgauge only ever sees the one it was configured to see.
  • Genuinely non-deterministic output: shape-capture (the default) absorbs most value churn, but a tool that returns a random SCHEMA shape will read as flaky drift.
  • stdio's narrower assertion set: the T-group framing assertions (SPEC §5) require an HTTP layer — over stdio they report n/a (stdio), with the reason printed, never silently passed.

Non-goals, by design: no security scanning, tool-poisoning heuristics, or trust scores; no LLM anywhere in the product; no re-implementation of the official conformance suite; no SARIF at v1; client-side (agent-side) conformance is out of scope — snapgauge tests servers.