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 codeAs 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.jsonThis 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 id | what it means |
|---|---|
| annotation.destructiveHint.raised | destructiveHint became true — the tool now declares it destroys data. |
| annotation.idempotentHint.revoked | idempotentHint was true and no longer is — retry logic built on it is now unsafe. |
| annotation.readOnlyHint.revoked | readOnlyHint was true and no longer is — clients treating the tool as safe now mutate. |
| capability.removed | A server capability disappeared from discover — features clients negotiated are gone. |
| error.code.changed | A probed call's error code changed — client error handling keyed on the code breaks. |
| tool.input.enum.removed | Enum value(s) removed from an input — clients sending them are now rejected. |
| tool.input.required.added | An input became required — clients recorded against the old contract do not send it. |
| tool.input.type.narrowed | An input's accepted value space shrank (type set reduced/changed, or an enum constraint introduced) — previously-valid calls are now rejected. |
| tool.output.required.added | An output field became required (SPEC §5 places this at breaking). |
| tool.removed | A tool present in the old contract is gone — clients holding the old contract fail. |
| version.dropped | A previously-advertised protocol version is no longer supported. |
| xhdr.added | An x-mcp-header binding appeared on an existing tool — a client on a cached tools/list will now be rejected with -32020. |
| xhdr.changed | An x-mcp-header binding's header name changed — clients still send the old header and are rejected with -32020. |
risky (12)
| rule id | what it means |
|---|---|
| annotation.destructiveHint.relaxed | destructiveHint was true and no longer is (annotation.*.relaxed family) — the tool now claims to be safer. |
| annotation.idempotentHint.relaxed | idempotentHint became true (annotation.*.relaxed family) — clients may now retry freely on its word. |
| annotation.readOnlyHint.relaxed | readOnlyHint became true (annotation.*.relaxed family) — an increased safety claim clients may act on. |
| cacheScope.widened | cacheScope went private → public — responses may now be shared across authorization contexts. |
| instructions.changed | Server instructions changed — system-prompt-adjacent steering text. |
| order.changed | tools/list order changed (same tool set, both sides stable) — churns prompt caches keyed on list order. |
| order.nondeterministic | Repeated tools/list calls disagree on order (3-repeat probe) — reported as instability, NEVER as a false breaking diff. |
| output.enum.added | Enum value(s) added to an output — consumers may not handle the new values. |
| tool.added | A new tool appeared — expands the agent's action surface (shadowing risk). |
| tool.description.changed | Description text changed — the trigger surface a model routes on (never cosmetic). |
| tool.title.changed | Title text changed — also routing surface. |
| ttlMs.raised | ttlMs increased — clients will serve stale surface data for longer. |
compatible (8)
| rule id | what it means |
|---|---|
| cacheScope.narrowed | cacheScope went public → private — strictly safer sharing semantics. |
| capability.added | A new server capability appeared in discover. |
| required.removed | An input is no longer required — clients that send it are still fine. |
| tool.input.enum.added | Enum value(s) added to an input — everything the old contract sent still works. |
| tool.input.optional.added | A new optional input appeared — old calls remain valid. |
| tool.input.type.widened | An input's accepted value space grew (type set expanded, or an enum constraint dropped) — old calls remain valid. |
| ttlMs.lowered | ttlMs decreased — clients refresh sooner. |
| version.added | A new protocol version is now advertised. |
cosmetic (4)
| rule id | what it means |
|---|---|
| _meta.vendor.changed | A tool's vendor _meta changed — vendor-prefixed keys carry no contract. |
| serverInfo.version.changed | serverInfo.version changed — releases are expected to happen. |
| text.whitespace-only | A description/title/instructions change that is whitespace-only — the one text change that IS cosmetic. |
| tool.icons.changed | Tool 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 id | class | what it means |
|---|---|---|
| compat.era | info | modern-only | dual | legacy, from a modern-then-initialize probe. |
| compat.version_advertised_unsupported | violation | A version listed in discover.supportedVersions fails a plain tools/list — the server is lying about support. |
| compat.set_varies_per_connection | violation | Same profile, two fresh connections, different tool set (MUST NOT). |
| degrade.wrong_error | violation | Under 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_declared | violation | -32021 names a capability the tool never actually exercises under modern-full — gating at request entry instead of at use. |
| degrade.silent | risky | A 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.reported | info | The good citizen: the result degrades under the reduced profile AND says so via the degradation marker. |
| xhdr.not_statically_reachable | violation | An 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 oneOutput: 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:
| code | meaning | who fixes it |
|---|---|---|
| 0 | Clean — no findings at/above the gate. | Nobody — nothing to do. |
| 1 | Drift 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. |
| 2 | Probe/connection failure (unreachable, auth, timeout, malformed response). | The consumer's environment/config — check the target URL, credentials, and network reachability. |
| 3 | Compat/degradation violation — the server is wrong, not merely different. | The server's maintainer — it violates the 2026-07-28 revision's degradation contract. |
| 4 | Usage/config/snapshot-format error (incl. probe-spec mismatch: re-record). | The consumer — fix the CLI invocation or config, or re-run snapgauge record. |
| 5 | Internal error — a bug in snapgauge itself. | snapgauge's maintainer — please report it (SECURITY.md). |
The ugly paths, by situation:
| situation | contract |
|---|---|
| Target unreachable, TLS failure, or timeout | Exit 2. No snapshot written, no partial diff. check never writes a snapshot. |
| Target returns 5xx mid-probe | That 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 snapshot | Exit 4 — "config changed; re-record." Never a silent partial comparison. |
| Stored snapshot's formatVersion is newer than this binary | Exit 4 with the required version. An older formatVersion migrates only with --migrate. |
| Auth token missing or expired | Exit 2, classified auth. The token is never written to the snapshot or the log. |
| Redirect on the MCP endpoint | Not followed. Recorded as a transport.redirect finding, not a hop. |
| Response over 256 KB, or a snapshot over 1 MB | Truncated, marked truncated: true — that probe cannot produce a breaking finding (evidence is incomplete). |
| stdio server hangs or dies | 10s per-request timeout, SIGKILL on teardown, exit 2. No orphan processes. |
| Hosted /live: target refused by the SSRF policy | 400 TARGET_NOT_ALLOWED with the reason class only — never the resolved address. |
| Hosted /live: rate limit or cost cap hit | 429, 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.