More from yellowgram: OSS tools.
Lock exact hashes of an MCP server’s list surfaces and fail CI when they drift silently:
- tools —
name,description,inputSchema,annotations,outputSchema - resources —
uri,name,description,mimeType - prompts —
name,description,arguments
This is deterministic byte-level hashing — not semantic / LLM / embedding drift detection. If a description character changes, the digest changes.
On mismatch, lockfile v3 also explains what changed with a deterministic field-diff (COMPATIBLE | BREAKING | HINT_FLIP). Pass/fail remains digest equality. Re-lock after upgrading to ≥1.4.
Spec: SPEC.md (Lockfile Spec v1.4; canonicalization surfacepin-jcs-v1 + lockfile v1/v2/v3).
Node 20+. From a clone, npm install once, then npm run demo. The demo builds and runs the CLI on committed fixtures only (no network, no API keys).
git clone https://github.com/yellowgram/surfacepin.git
cd surfacepin
npm install
npm run demonpm run demo proves exact-hash lockfile verify on committed fixtures (testdata/basic.tools.json against basic.v3.lock.json, and multi-surface basic.surface.json) and that a one-character description drift fails verify. Pass/fail is digest equality. HINT_FLIP is a field-diff label, not a safety verdict.
# Installed package: npm install -g surfacepin
# or: npx surfacepin … / npm install surfacepin --save-dev
# Lock (writes lockfile v3 with embedded surfaces for structured diff)
surfacepin lock tools.json -o surfacepin.lock.json
surfacepin verify tools.json surfacepin.lock.json
surfacepin diff tools.json surfacepin.lock.json
# Multi-surface
surfacepin lock surface.json --surface tools,resources,prompts -o surfacepin.lock.json
surfacepin verify surface.json surfacepin.lock.json --surface tools,resources,promptsFile mode accepts a combined dump { "tools": [...], "resources": [...], "prompts": [...] } (or List*Result-shaped). Missing selected keys → empty.
Verify still accepts older v1 / v2 lockfiles (digest-only). Re-lock to get v3 field-diff.
surfacepin lock --stdio -- npx -y @modelcontextprotocol/server-everything
surfacepin lock --stdio --surface tools,resources,prompts -- npx -y @modelcontextprotocol/server-everything
surfacepin verify --stdio --surface tools,resources,prompts surfacepin.lock.json -- npx -y @modelcontextprotocol/server-everythingEverything after -- is the server command + args. Servers lacking a capability → empty list + stderr note.
Exit codes: 0 match, 1 drift, 2 usage/parse error.
Commit surfacepin.lock.json. Re-lock when you intentionally change the surface. The default contributor loop is Five-minute path.
When a tool’s inputSchema drifts under a v3 lock:
DRIFT: surface does not match lockfile
CHANGED echo
f35d75b2… -> a6fdd0e9…
CHANGED description (COMPATIBLE)
"Echo text back" -> "Echo text back (updated)"
ADDED inputSchema.properties.lang (COMPATIBLE)
+ {"type":"string"}
CHANGED inputSchema.properties.text.type (BREAKING)
"string" -> "number"
ADDED inputSchema.required["lang"] (BREAKING)
+ "lang"
CHANGED annotations.readOnlyHint (HINT_FLIP)
true -> false
Machine output: surfacepin diff … --json.
CLI is a client of these. No LLM on the gate.
import { pin, verify, diff, pinStdio, verifyStdio } from "surfacepin";
const { lockfile, text } = pin(doc); // lockfile v3
const { ok, diff: report } = verify(doc, lockfile);
const live = await verifyStdio({
command: "node",
args: ["server.mjs"],
lockfile,
});Foreign default path (official SDK createServer + this library as the gate): npm run sdk-path.
npm run demo is the fixture proof above (npm install first; the script builds). Unit tests and other local CLI checks:
npm test
node dist/cli.js lock examples/tools.json -o /tmp/sp.lock.json
node dist/cli.js lock testdata/basic.surface.json --surface tools,resources,prompts -o /tmp/sp-multi.lock.json
node dist/cli.js lock --stdio --surface tools,resources,prompts -- node testdata/stub-mcp-server.mjsLock a live MCP server over stdio, commit surfacepin.lock.json (that exact name), and verify that same file in GitHub Actions and a pre-commit hook.
Pass/fail is exact-hash digest equality. HINT_FLIP is a field-diff label, not a safety verdict.
Offline stub already in this repo (no network):
surfacepin lock --stdio --surface tools,resources,prompts -- node testdata/stub-mcp-server.mjsThat writes surfacepin.lock.json. The committed golden for this stub is testdata/basic.multi.lock.json (same bytes). A tools-only server uses the same command without --surface.
Same shape against the everything reference server (needs a network fetch of the package):
surfacepin lock --stdio --surface tools,resources,prompts -- npx -y @modelcontextprotocol/server-everythinggit add surfacepin.lock.jsonDo not rename it.
Pin @v1.5.0. Do not pin @v1.
uses ref |
Hasher you get |
|---|---|
yellowgram/surfacepin/action@v1.5.0 |
Exact tag. Preferred. Reproducible hasher at tip 1.5.0 (multi-surface + stdio inputs below). |
yellowgram/surfacepin/action@v1 |
Floating major tag. It may move to any commit inside 1.x, so the hasher can change with no workflow edit. Today it still names the older file-mode action (tools-path + lockfile-path only). |
GitHub Action (live stdio — the command after -- in the CLI):
# Pin the exact tag. @v1 floats inside 1.x and is not a reproducible hasher.
- uses: yellowgram/surfacepin/action@v1.5.0
with:
lockfile-path: surfacepin.lock.json
surface: tools,resources,prompts
server-command: node
server-args: testdata/stub-mcp-server.mjsFile mode still works when you commit a dump instead of a server command (same exact pin):
- uses: yellowgram/surfacepin/action@v1.5.0
with:
tools-path: testdata/basic.surface.json
lockfile-path: testdata/basic.multi.lock.json
surface: tools,resources,promptstools-path + lockfile-path with no surface remains the tools-only file check (examples/tools.json).
Pre-commit uses .githooks/pre-commit (no extra dependencies). Enable it once per clone:
git config core.hooksPath .githooks
chmod +x .githooks/pre-commitnpm run build first so dist/cli.js exists (or install surfacepin so the bin is on PATH). This repo’s surfacepin.precommit verifies testdata/basic.multi.lock.json with node testdata/stub-mcp-server.mjs and --surface tools,resources,prompts — the same stdio check as the Action. For your server, edit that file:
LOCKFILE=surfacepin.lock.json
SURFACE=tools,resources,prompts
SERVER_COMMAND=node
SERVER_ARGS=server.mjs
TOOLS=
Environment variables (SURFACEPIN_LOCKFILE, SURFACEPIN_SURFACE, SURFACEPIN_SERVER_COMMAND, SURFACEPIN_SERVER_ARGS, SURFACEPIN_TOOLS) override the file. Set SURFACEPIN_SERVER_COMMAND empty and SURFACEPIN_TOOLS to a JSON dump for file mode.
Pin yellowgram/surfacepin/action@v1.5.0. Tip is 1.5.0: multi-surface (surface) and live stdio (server-command / server-args). An exact tag keeps the hasher reproducible. Another exact tag (@v1.4.0, and so on) is also reproducible; it will not include inputs added after that tag.
@v1 is a floating major tag. GitHub resolves it to whatever commit v1 names, and that ref may move within 1.x. A workflow that says @v1 can run a different hasher without a file change. That is not a reproducible pin. Today v1 still points at the older file-mode action, which does not accept surface or server-command.
- uses: yellowgram/surfacepin/action@v1.5.0
with:
lockfile-path: surfacepin.lock.json
surface: tools,resources,prompts
server-command: node
server-args: testdata/stub-mcp-server.mjsComposite action under action/. Inputs:
| Input | Required | Role |
|---|---|---|
lockfile-path |
yes | Path to surfacepin.lock.json |
tools-path |
file mode | Surface JSON dump (tools-only or combined) |
server-command |
stdio mode | Executable; runs surfacepin verify --stdio … <lock> -- <command> [args] |
server-args |
no | Arguments (whitespace-separated, or one per line) |
surface |
no | tools, resources, prompts (comma-separated). Omit for tools only |
working-directory |
no | Default . |
Set tools-path or server-command, not both. surface must match the lockfile. Lockfile name stays surfacepin.lock.json (lockfile v3). See Five-minute path for file mode and pre-commit.
This repo’s CI calls ./action (the copy on the pull request), not the published tag. Adopters pin @v1.5.0.
| Does | Does not |
|---|---|
| Hash tools (+ annotations, outputSchema) + resources + prompts | Semantic similarity gates |
| Lockfile v3 (embedded surfaces) + verify v1/v2/v3 | Streamable HTTP / SSE (stdio only) |
| Deterministic field-diff: COMPATIBLE / BREAKING / HINT_FLIP | LLM / fuzzy matching; safety verdicts from hints |
| Offline verify from JSON files | Hosted service, telemetry, signed locks |
Live list* via MCP stdio (--stdio -- …) |
Resource templates / initialize.instructions |
| Ignore tool title / icons / _meta | Materialize MCP annotation defaults into hashes |
GitHub Action file mode and live --stdio (optional --surface) |
A safety verdict from HINT_FLIP |
As of 1.5.0 the Action and the pre-commit hook run the same verifies as the CLI, including multi-surface and live stdio. Lockfile format is unchanged. Pin the Action at @v1.5.0; floating @v1 may move within the major.
OSS SurfacePin stays free and offline — verify never needs our servers. For a $99 founding reservation of the private-repo PR check: https://www.yellowgram.dev/surface-guard. A failed surfacepin verify prints that same offer once on stderr.
MIT © 2026 yellowgram