A version number is a promise: nothing a client depends on changed without me saying so. For MCP servers, almost nothing checks that promise.
The ...
For further actions, you may consider blocking this person and/or reporting abuse
The lock makes the publisher keep the promise, but a client still can't check it, because the hash lives in your repo and the registry entry carries only the version. I'd publish the section hashes with each release. server.json already has room for it: the registry accepts custom metadata under _meta, in the io.modelcontextprotocol.registry/publisher-provided key. An agent host could then compute the same normalised hash from initialize and tools/list on first connect, and refuse or ask for re-approval when it differs from what the registry lists for that version.
That would have caught the instructions move in the worklore case from the client side, without anyone running your replay. It does depend on the normalisation being written down somewhere other than your code, so a host built by someone else hashes the same bytes. A short spec of the canonical form in the mcp-surface README would cover it.
You were right, and it's done. Thank you — this was the half I was missing.
From today, each release of my seven servers publishes the fingerprint in its MCP Registry entry, under
_meta→io.modelcontextprotocol.registry/publisher-provided: the sha256 of the declared surface (initialize, tools, resources, prompts) and which methods answer without a credential on the published endpoint. A host can recompute it on first connect and refuse, or ask again, when it differs from what the registry lists for that version.On the second point, the canonical form now lives in a SPEC.md rather than only in my code, with a test vector. Writing it down is what exposed two things that would have made a host built by someone else hash different bytes: lists were sorted with
localeCompare(locale and ICU dependent, now UTF-16 code-unit order), and the capture didn't follownextCursor. Neither changed any existing hash. There's also a second implementation, written from the spec with no dependencies (verify.mjs), and the tests require both to agree:The release pipeline now ends with the same check a client would make: registry entry against the live endpoint, without reading the lock.
What it proves: what you run is what the registry published for that version, and the registry won't let that entry change; with the source, anyone can also check that the hash is the public code's (SPEC §6.1). What it can't prove is that a declared tool is benign — that stays a human decision on re-approval.
One gap left on the host side: the check runs only on first connect. A server can answer tools/list one way at the start of a session and another way twenty minutes in, and the spec only says a server SHOULD send notifications/tools/list_changed when its list changes. A server that wants to switch quietly just won't send it. I'd have the host hash every tools/list response it receives, not only the first, and treat a mid-session mismatch with the registry entry the same as one on connect. Your capture already follows nextCursor, so it's the same function called more often.
SPEC.md seems like the right place to say it: the promise holds per response, not per connection.
Agreed, and it's in the spec now: SPEC §6.2.
One wrinkle shaped the wording. The registry hash covers the whole surface, never tools/list alone, so a later response can't be hashed by itself. The host swaps the new list into the surface it captured on connect and rehashes all of it. That keeps every hash already published valid, with no new field. The same goes for the other three lists.
Tried it today. worklore 0.5.1 now publishes the sha256 of every part of the surface a stranger can reproduce without a token (initialize without serverInfo.version, tools/list, and which methods and tools answer anonymously) in server.json under _meta → io.modelcontextprotocol.registry/publisher-provided. The deploy check recomputes them from the live server and compares them with the registry entry, the way a client would, and the README has a 20-line script so anyone can do the same: github.com/worklore/worklore-mcp#v...
One thing I left out on purpose: resources/list. Mine lists stories, so it changes every day, and its hash would break the promise daily without anything actually breaking. Thanks for the idea; it was the missing half.
Thank you — your cut is the one I used. What goes to the registry is only what a stranger can reproduce without a token: the declared surface, and which methods answer anonymously in the production configuration. The keyed variants stay in the lock, where only I can measure them.
One difference from yours: I kept
resources/listin. I measured the seven servers and none of them lists anything that changes, so here it holds still. Your reason for leaving it out is now written into the spec as the rule: a list that changes on its own does not belong in a promise. Your 20-line script becameverify.mjs, and it is the second implementation the tests compare against.Section 6 made me think the lock has a hidden input: the configuration it was measured under. With SNOMED behind a licensing flag,
1.1.xreally has two surfaces, and the lock only describes the default one. A user who turns the flag on gets six tools the lock never saw, and if one of those tools changes later, nothing goes red.Two cheap options I can think of. Record the env and flags used for each capture inside the lock, so the hash explicitly means "surface under config X". Or lock each documented flag combination as its own section. The second also helps the replay, since a minor that moves tools behind a flag would show up as moved rather than removed.
Per-tool hashes inside the
tools/listsection would help in a similar way, so a red test names the tool that drifted.Are flag-gated tools common across your seven servers, or is SNOMED the only one?
Good catch on the hidden input. In 1.1.x that was exactly the case: the six SNOMED tools only registered with the licensing flag on, so the lock described the default surface and nothing else.
That surface no longer exists: SNOMED was retired in 2.0.0, and the server has one surface again. Across the seven servers, no tool is gated by an env flag today. The one server with two surfaces (Senate) switches by route: the full set versus a smaller ChatGPT-app profile. Its lock stores each profile as its own section of the content, which is your second option. Auth configuration was already explicit: the no-token section is measured with the key absent and present. What the declared section still doesn't record is the configuration it was captured under. It's implicit in the test that captures it, and if a flag ever comes back, your first option is the fix.
Your third point I did today, slightly differently. Per-tool hashes would change the canonical form, and with it every hash already published in the registry. But the lock already stores the full content, so as of @sbissoli/mcp-surface 0.5.2 a red lock diffs it and names the drift: full.tools[senado_agenda_plenario].description: changed; full.tools[senado_ceaps]: removed.
Thanks — that one will save me a JSON diff by hand next time.
Thank you, Sidney, and what a follow-up: 142 versions replayed is the part I'd never have done. Two things grew on my side from the same thread. Since 0.5.0 my lock also records which tools answer without a token, after I made search and reading public and nearly shipped it without noticing that it changed the surface. And your section 8 hit home: my live check now asks a made-up method first, and if that gets a result, it refuses to compare anything, because a probe that can't say "no" proves nothing.
Thanks, Valentina.
Before today mine only half-did it. The no-token probe checked one method that must answer and one that must not before writing the lock. But the post-deploy check against the live endpoint never asked for a method that doesn't exist. A yes-to-everything endpoint would only have shown up as a diff someone had to read.
As of @sbissoli/mcp-surface 0.5.1, verificar and the registry check ask for a made-up method first, and refuse to compare if it gets a result. The standalone verify.mjs does the same, and the spec gained that as step 2 of "checking it". Before publishing I ran it against all seven servers in production; they all say no, so it would have been the canary rather than the culprit.
Glad the anonymous-surface piece is in your lock too.
The null vs [] distinction is the detail I keep coming back to. "No prompts" and "prompts/list isn't served" really are different surfaces, and most diffing tools I've used collapse them into the same empty result.
The crashed 1.1.0 and 1.1.2 releases hit close to home. I've been bitten by esbuild emitting ESM that still keeps a CommonJS require for a Node builtin, and it only shows up when somebody actually installs that old version. Registry metadata never catches that.
One question: does the lock hash each tool's inputSchema as part of tools/list, or just names? A changed argument shape on an existing tool has been the most common silent break in my experience, and I might be missing where that's covered.
Thanks — and yes, the esbuild case is exactly the kind of break that only shows up at install time. That's why the replay installs each old version instead of trusting the registry.
To your question: the whole tool, not just the name. Each item from tools/list goes into the lock as the server sends it — name, title, description, inputSchema, outputSchema, annotations — so a changed argument shape on an existing tool changes the sha256, and the failing test names the tool and the field that moved.
But your question exposed a real gap one step further: the lock saw the change, it didn't judge it. Any version bump was enough to re-lock, so a type change or a narrowed enum could ship as a minor. As of @sbissoli/mcp-surface 0.6.0 (out today), the lock rule classifies breaks — on the input, a parameter type or enum value no longer accepted, a tightened bound, a newly required parameter (nested properties included); on the output, a guaranteed field removed or made optional, or a value the client didn't expect — and refuses to re-lock them unless the major goes up.
Before shipping it I ran the rule over the git history of all seven servers' locks: 35 surface changes, one break found — the release that retired SNOMED, which had gone out as a major — and zero releases it would have wrongly blocked. All seven servers now run it.
The rule is described in the package README: github.com/SidneyBissoli/mcp-br-co... — thanks for the question.
The test double returning valid results for every method in section 8 is the exact failure mode that bites snapshot-based contract tests. If the harness cannot prove an invalid method or missing capability produces an explicit protocol error, the snapshot only records whatever the mock felt like emitting.
On the agent client side, the nastiest drift I hit with MCP servers is silent edits to tool descriptions. Under conventional semver, rewording a description looks like a no-op documentation tweak that never warrants a minor bump. Because agent runners inject tool definitions directly into the system prompt, a one-word change in a description busts prompt cache prefixes across active sessions and can shift tool selection behavior without a single parameter changing. Locking description hashes alongside schema definitions saves hours of debugging mysteriously evaporated cache hit rates.
Thanks, Reid — and yes, the double that answers everything is the trap I'd most like people to avoid.
On descriptions: the lock already covers them, though the article didn't make that explicit. It hashes each tool's full object — description, input and output schemas, annotations — plus the server's instructions, so a one-word rewording turns the build red until the version moves. It got exercised today: I reworded tool descriptions on six servers to say what each number is and which version of it, and the lock refused every one until it got a minor bump.
Your prompt-cache angle is a better argument for that rule than the one I gave. I framed it as "the client sees a different surface"; you're pointing at a cost the client pays even when nothing about the parameters changed. Thanks for that.