Pinning MCP tool contracts: five ways we got it wrong
GB - mcpindex.ai - August 25, 2026
The design fits in a paragraph. When you approve an MCP tool, hash its declared contract and store the hash. Re-hash on every connect. If it moved, treat the tool as unapproved and make a person look at the diff. That is SSH host-key checking pointed at tools/list, and the argument for doing it at all is a separate post.
We shipped it. Five things broke that the paragraph does not warn you about. If you are building the same mechanism, this is what to expect.
1. Sorted keys is not a canonical form
The obvious serialization is your language's JSON encoder with sorted keys. It gets you most of the way, then produces mismatches nobody can explain.
Floats are where it starts. A lossy format collides two schemas that genuinely differ, so we went to %.17e, which round-trips. Then the cross-language edge showed up underneath it: Number.toExponential(17) in JavaScript does not zero-pad the exponent, so JS emits 1e+5 where Python emits 1.00000000000000000e+05. Same number, different bytes, different hash, and the pin fails on a contract that never changed.
The rest of the list is shorter and just as load-bearing. Collapse -0.0 to 0.0. Normalize strings and object keys to Unicode NFC, or an NFD-encoded description hashes differently from an NFC one that renders identically on screen. Reject non-finite floats instead of encoding them. Cap the recursion depth, because a deeply nested schema arriving from a server you do not control should raise instead of exhausting the stack.
Write the canonical form down as a spec. Whatever your serializer happens to do today is not a spec, and the first port to another language is where you find out.
2. Your hash probably does not cover the annotations
Ours did not. The per-tool hash was name + description + inputSchema, which is the natural set. It is what a caller sends and what the model reads.
annotations sits outside it. So does outputSchema. A server could move readOnlyHint to destructiveHint and that hash would not move, which means a tool can go from declaring it reads to declaring it writes while riding the pin you approved. The fix was a second, wider hash over outputSchema, annotations and title, with drift comparison keyed on the wide one.
There is a third surface no per-tool hash reaches at all. A server's instructions arrive on initialize, and its prompt metadata is its own list. Neither belongs to any tool, both go into the model's context, and a server can leave every tool byte-identical while changing the standing instruction the agent reads. That needs a server-scoped hash of its own. One detail from building it: hash the content-bearing fields only. We excluded the observation-status fields, because ok versus ok-truncated flutter on a large instructions blob would otherwise manufacture drift that nothing actually changed.
3. A restart is not a first use
This one came out of dogfooding.
Our stdio proxy wired its pin in memory. Within a session it behaved exactly as designed: re-pull on notifications/tools/list_changed, compare against the pin, hold on a mismatch. Restart the host and the pin was gone, so the next launch pinned whatever the server happened to be serving at that moment and called it the baseline.
A contract that changed while your machine was off gets absorbed as trusted. No error, no prompt, no record anywhere that it happened. Trust-on-first-use becomes trust on every startup.
Persist the pin keyed by server and tool, with the timestamp you pinned it at. Re-pinning should be a thing a person does on purpose.
4. Prompt on every change and you have built a click-through
Across 30 daily baseline snapshots we found 93,419 tool pairs where the description changed while the input schema did not, and 88,564 of them differed only in digits. Counters and clocks re-rendered into a templated description on every crawl: "Returns 45 results" becomes "Returns 47 results" and the hash moves.
That ratio is 94.8% and it would be wrong to quote it as a property of the ecosystem. 90,101 of the 93,419 pairs come from a single publisher whose fleet stamps a shared catalog counter into every server it operates, so one number ticking moves tens of thousands of pairs at once. Deduplicate by publisher and the digits-only rate is 528 of 3,318, which is 15.9%. Both figures are worth having and they answer different questions. 15.9% is what the ecosystem does. 94.8% is what your prompt experiences if you have that one fleet wired, and a design has to survive the second case as well as describe the first.
A pin that prompts on any hash change fires on all of that. People approve everything inside a week, and then the one prompt that mattered gets the same reflex as the two hundred before it. The prompt is still technically correct and operationally worthless.
So classify before you prompt. We run a structured diff over the two contracts and sort every delta into a fixed taxonomy, because a newly required parameter is a different event from a removed one, and both are different from a re-rendered counter. Three kinds auto-accept: an added optional parameter, a newly added tool, and a first-time outputSchema where there was none before. None of those can break a caller that was already working. Everything else holds for a person. Digits-only description changes get their own kind too, and it is worth being careful about why, because the obvious reason is wrong. Across the whole corpus numeric changes outnumber genuine edits 18 to 1, which makes the split sound like it rescues a real edit from burial. Outside the fleet that ratio inverts: 2,790 genuine against 528 numeric, better than five to one the other way. Nothing is being buried out there. The split earns its place by protecting the clients that ARE wired to a counter-stamping fleet, where the ratio is 43 to 1.
One qualification we had to add after the fact, because "digits only" reads as "ignorable" and it is not always. We went back and walked the digit runs that actually changed, asking of each whether it sits inside a token that carries meaning. 144 of them, across 123 tools, sit inside a currency amount: a per-call price quoted in the description as $0.001 moves to $0.002, nothing structural changes, and the classifier correctly calls it cosmetic because by its own definition it is.
Against the whole corpus that is 0.16% of the digits-only set, which is what I first wrote down and it is the wrong denominator. Not one of the 144 comes from the counter-stamping fleet, so it belongs against the deduplicated population: 144 of 528, or 27%. Counted in tools instead of pairs it is 123 of the 215 non-fleet tools that had any digits-only churn, which is 57%. Take the fleet out and roughly one change in four moves digits inside a currency amount, and a majority of the tools involved have done it at least once. Mind which unit you are quoting there, because the two land on opposite sides of a half and it is easy to carry the wrong one. Either way it is why that kind is deliberately not on the auto-accept list.
The classifier is what makes the pin survivable. Without it you have a correct mechanism nobody leaves switched on.
5. The pin store is writable by the thing it pins
Our proxy takes --pin-store <path> and compares every tool definition against whatever is at that path.
On 2026-08-13 we reproduced the consequence against our own published 0.13.2. A config entry carrying a planted --pin-store graded fully enforcing, tool calls flowing, status reporting protection, while every comparison ran against a file the entry itself had chosen. Two bypasses we had fixed the week before both made a server fail to start, which is loud. This one let it run.
The part worth stealing is why that is worse than merely pointing at a wrong file. The index that separates a DELETED baseline from a genuine first run lives inside the store's own directory. Relocate the store and you relocate its evidence with it, so deleting the planted file reads as a clean first run and re-pins whatever the server is serving right then. The two states you most need to tell apart become the same state.
Two rules came out of it. A baseline your own tooling cannot read does not count as coverage, so an entry like that now lands in a separate unverifiable bucket that stays in the denominator and out of the protected count. And the directory you treat as visible has to resolve from the password database rather than $HOME, because a wired entry's env is copied onto the proxy, and a $HOME-based resolver hands the thing you are grading the ability to move your idea of where visible is.
MCP servers run as the same user as the client that launched them. The pin store is inside their reach by default.
What the pin still does not cover
A pin is a record of what you approved. It cannot tell you the approval deserved to be given, and it cannot see a change in what a tool does while its declaration holds still. Annotations are advisory in the spec, so a server whose behaviour changes without its declaration changing moves nothing you are hashing. We have not solved that one and do not claim to.
What the pin does cover is the case where the declaration itself moves, which happens considerably more than the design of MCP suggests it should. Our crawler diffs public servers daily and the running counts live on the public ledger rather than frozen into this sentence, for the obvious reason.
Does yours hold?
If you have built this, I want to know which of the five bit you and what the sixth one was. If you have not built it yet, the order that would have saved us the most time is canonical form first, then what goes in the hash, then persistence. Prompt fatigue only shows up once people are actually using it.
- How do you pin an MCP tool contract?
- Hash the tool's declared contract through a spec-defined canonical serialization when you approve it, store the hash keyed by server and tool, re-hash on every connect and on notifications/tools/list_changed, and hold the call if it moved. Include the annotations and outputSchema in the hash, not just name, description and inputSchema.
- Why is hashing the tool definition with sorted keys not enough?
- Sorted keys leaves number and string encoding implementation-defined. Float rendering differs between languages, -0.0 and 0.0 encode differently, and NFC versus NFD Unicode produces different bytes for text that renders identically, so two correct implementations disagree about whether a contract changed.
- Can a contract pin detect behavioural drift?
- No. A pin compares declared contracts. If a tool's implementation changes while its declaration holds still, the hash does not move. MCP annotations are advisory hints, so a server whose behaviour changes without touching readOnlyHint moves nothing a pin is hashing.