Your MCP Pin Blocks Every Update. Most Never Broke You.


A month ago I shipped a 40-line padlock for MCP tools: pin the manifest hash, block the rug-pull. It works. It also has a flaw I wrote about in the last paragraph and then had to go build a fix for, because it kept nagging me. The pin fires on any change to a tool’s definition. A server that honestly adds an optional parameter trips it exactly as loud as a server that yanks a required one out from under you. Same alarm, same block, same 3am page. The pin knows the contract changed. It has no idea whether the change broke you.

In short: an MCP tool schema breaking change narrows the valid-call set, so a call your agent makes today stops validating tomorrow. A backward-compatible change widens or leaves that set alone, so old calls stay valid. A byte pin cannot tell them apart. compat_gate.py diffs the inputSchema and replays the calls your agent recorded: silent on compatible, fail-closed on breaking.

AI disclosure: I wrote compat_gate.py with an AI assistant and ran every case myself before publishing. Every terminal block below is pasted from a real run on Python 3.13.5. The compatible case (C2) is a real dated diff between two published MCP spec schemas, fetched over curl and checksummed; the breaking cases (C3 to C5) are synthetic fixtures I built and confirmed by replay, and I label them as such. I have no production incident to sell you here; bot2 is new and its lifetime run count is zero. What I have is a tool that runs and a diff you can reproduce.

Why does pinning a manifest fire on every change?

Because a hash has one bit of memory: same, or different. The June pin takes a canonical SHA-256 over name + description + inputSchema and blocks when the live manifest stops matching the one you approved. That canonicalization is already the good kind of pin: it sorts keys and strips whitespace, so a re-serialized-but-identical manifest does not trip it. I checked that first, and it holds. The pin is not paranoid about JSON formatting.

It is paranoid about meaning. And that is the problem, because most meaning changes to a schema are harmless to your calls. The MCP spec itself is the clearest evidence. The protocol ships dated, breaking-capable revisions on a cadence; the schema directory in the spec repo currently carries 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, and a live draft/. I pulled two of those and diffed them by hand (more on that below). Servers track the spec. Tools get re-versioned. Descriptions get rewritten for the model. Every one of those events flips the pin’s one bit, and the pin dutifully blocks.

Here is the open question I left myself in June and could not answer from my own toolbox: where is the line between a legitimate tool update and drift? The franchise on this blog keeps landing on the same shape. Tracking is not control. Having a credential is not scoping it. Approval is not immutability. This post adds the next line: a changed contract is not a broken one. A pin is not a compatibility check. The pin tracks change. It does not control whether a change that would break your agent reaches the call. That is a different job, one level down from the hash, and it needs a different tool.

What makes a tool-schema change breaking?

There is a ground truth here, and it is older than MCP. It is subtyping. A schema describes a set: the set of argument objects it calls valid. A change is backward-compatible when the new set contains the old one, so every call that used to validate still validates. A change is breaking when the new set is smaller, so some previously-valid call now fails. That is the whole test, and it does not require an opinion.

On an object inputSchema, the breaking moves are the ones that shrink the valid set:

  • Add a required property. Old calls that omit it were fine and now are not.
  • Remove a property when additionalProperties: false. A call that passed that key is now rejected as an extra.
  • Narrow a type. number to integer throws out 3.5. Any incompatible type swap can strand an old value.
  • Shrink an enum. Drop "csv" from ["json", "csv"] and every call that asked for CSV is gone.
  • Make an optional property required, or tighten additionalProperties from true to false.

And the compatible moves, the ones that widen or preserve the set:

  • Add an optional property. Nobody was required to send it; old calls are untouched.
  • Widen an enum, relax a type (integer to number), drop a required constraint, or rewrite a description. None of these can invalidate a call that was already valid.

That list is the entire logic of the classifier. The point of the tool is not to be clever about it. The point is to run it against real schemas and, critically, to check the classifier’s verdict against something independent: the calls themselves.

The gate: replay the calls your agent actually makes

compat_gate.py is keyless, offline, and stdlib-only (json, sys, hashlib). It carries a deliberately small JSON-Schema validator that understands exactly six keywords: type, required, properties, enum, additionalProperties, const. Anything else in a schema (a format, an items, a $ref) is treated as no constraint. That is a real limit and I come back to it at the end. It is the right subset for the corpus here and small enough to read in one sitting.

Two signals run side by side, and they are meant to agree:

def classify(old_is, new_is):
    """Diff two inputSchemas. Return [(verdict, reason), ...] in a stable order."""
    changes = []
    old_props, new_props = old_is.get("properties", {}), new_is.get("properties", {})
    old_req, new_req = set(old_is.get("required", [])), set(new_is.get("required", []))
    old_ap = old_is.get("additionalProperties", True)
    new_ap = new_is.get("additionalProperties", True)

    for p in sorted(new_props):
        if p not in old_props:
            if p in new_req:
                changes.append(("BREAKING", "added required property '%s' (old calls omit it)" % p))
            else:
                changes.append(("COMPATIBLE", "added optional property '%s' (old calls stay valid)" % p))
    # ... removed props, newly-required props, additionalProperties, enum shrink/widen, type narrow, const ...
    return changes

That is signal (A), the subtyping classifier: it labels each field-level change COMPATIBLE or BREAKING with a reason. Signal (B) is the one I trust more, because it does not reason at all. It replays every recorded call against both schemas:

    replay = []  # (idx, call, valid_under_old, valid_under_new, errs_new)
    for i, call in enumerate(calls, 1):
        vu_old = not validate(call, old_is)
        errs_new = validate(call, new_is)
        replay.append((i, call, vu_old, not errs_new, errs_new))
    control_ok = all(r[2] for r in replay)               # every recorded call must be valid under old
    broken = [r for r in replay if r[2] and not r[3]]    # valid under old, invalid under new

Every call in the corpus has to be valid under the old schema. That is the entry criterion and I print it as valid_under_old=True on every line, as a negative control: if a recorded call is not valid under the schema it was recorded against, the fixture is wrong and the tool says so. Then each call is validated against the new schema. A call that was valid under old and is invalid under new is a broken call, and the count of broken calls is a measurement, not a label. The classifier can call a change breaking; the replay tells you whether it broke your traffic.

Quick start is two commands:

# just classify the schema delta:
python3 compat_gate.py classify old_tool.json new_tool.json

# the gate: classify + replay recorded calls, exit code is your CI signal:
python3 compat_gate.py gate old_tool.json new_tool.json calls.jsonl

gate returns 0 when there are zero breaking changes and zero broken calls, 1 when either fires, and 2 on a usage error. The exit code is the whole product. It drops into CI or a pre-connect hook with no daemon and no account.

Three changes, three verdicts, one tool

I ran the gate against six changes. One is real; five are synthetic fixtures on a made-up run_query tool (a read-only SQL tool: required sql, optional limit, format as an enum of ["json","csv"], dry_run, timeout_ms). Four recorded calls, none of them touching timeout_ms. Here is the null case first, because a gate that screams at everything is useless.

C1, identical (re-serialized). The new tool is the old one with keys reordered and whitespace changed. Same content.

### C1 identical (re-serialized)
byte-pin: ok
subtyping: 0 changes
compat verdict: PASS   breaks=0/4

Both silent. The canonical pin does not trip on re-serialization, and the gate finds nothing to break. Good. Neither tool is trigger-happy on a non-change.

C2, a real backward-compatible diff from the MCP spec. This is the one I care most about being honest on. I fetched two published MCP schema files, 2025-06-18 (sha256 b3db8f1c...) and 2025-11-25 (sha256 7b2d96fd...), and pulled the Implementation object out of each. Implementation is the serverInfo/clientInfo payload in the handshake; it is a JSON Schema with properties and required, the same shape a tool inputSchema is, which is why I can drive the gate with it. Between those two dated versions it gained three optional properties (description, icons, websiteUrl) and kept required at ["name", "version"]. Textbook additive.

### C2 real MCP additive diff (Implementation)
byte-pin: BLOCK
  [COMPATIBLE] added optional property 'description' (old calls stay valid)
  [COMPATIBLE] added optional property 'icons' (old calls stay valid)
  [COMPATIBLE] added optional property 'websiteUrl' (old calls stay valid)
compat verdict: PASS   breaks=0/3

The pin blocks. The bytes changed, so the hash changed, so the pin does its one job and refuses. The gate replays three recorded handshake instances ({"name": "example-server", "version": "1.0.0"} and friends), all valid under old, all still valid under new, and passes in silence. This is half the falsifier for the whole thesis: on a genuinely compatible change, the gate has to be quiet, or it is just a slower pin. It is quiet.

C3, an added required parameter. The new run_query requires a tenant_id. This is the shape I most wanted to fail closed on, because it is the common one: a server tightens a tool and every existing integration silently starts sending calls that will be rejected.

### C3 added required param 'tenant_id'
byte-pin: BLOCK
  [BREAKING] added required property 'tenant_id' (old calls omit it)
  call#1 valid_under_old=True valid_under_new=False [BROKEN] {"sql": "select 1"}
        -> $: missing required 'tenant_id'
  ...
compat verdict: FAIL   breaks=4/4

Four of four recorded calls break, each because it is missing tenant_id, and the gate names them. The gate command exits 1. That is the other half of the falsifier: on a breaking change, the gate must fail closed and point at the call. It does, and I checked the exit code by hand, because a gate that prints FAIL and exits 0 is not a gate.

C4, a shrunk enum. The format enum drops "csv". Only one recorded call asked for CSV.

### C4 enum shrank on 'format'
byte-pin: BLOCK
  [BREAKING] property 'format' enum shrank ['csv', 'json']->['json'] (dropped ['csv'])
  call#3 valid_under_old=True valid_under_new=False [BROKEN] {"sql": "select id, email from users", "format": "csv"}
        -> $.format: 'csv' not in enum ['json']
compat verdict: FAIL   breaks=1/4

One of four. Not four of four. This is why the replay matters and the classifier alone does not: “breaking” is true, but the blast radius on your traffic is a single call, and the number tells you that. breaks=1/4 is a different operational decision than breaks=4/4.

C5, breaking by schema, silent on your traffic. The new tool removes the optional timeout_ms under additionalProperties: false. By subtyping that is breaking: a call that sent timeout_ms would now be rejected. But none of the four recorded calls ever set it.

### C5 removed optional 'timeout_ms'
byte-pin: BLOCK
  [BREAKING] removed property 'timeout_ms' under additionalProperties:false (a call using it is now rejected)
compat verdict: FAIL   breaks=0/4

Here the two signals disagree, and the tool refuses to hide it. The classifier says breaking; the replay says breaks=0/4. The verdict is FAIL, because I would rather a gate be conservative on a schema that genuinely narrowed. But the printout names the split out loud: breaking by schema, 0 recorded calls touch it, silent on your traffic. That middle cell is the honest one. If I collapsed it to a clean PASS I would be lying about the schema; if I collapsed it to a clean FAIL with no annotation I would be hiding that your recorded traffic is fine. So it prints both.

Byte-pin versus compat-gate on the same changes

Here is the head-to-head, straight from the run. The byte-pin column is the June canonical hash, my own prior tool, not a strawman.

change                                   byte-pin   compat   breaks
------------------------------------------------------------------------------
C1 identical (re-serialized)             ok         PASS     0/4
C2 real MCP additive diff (Implementatio BLOCK      PASS     0/3
C3 added required param 'tenant_id'      BLOCK      FAIL     4/4
C4 enum shrank on 'format'               BLOCK      FAIL     1/4
C5 removed optional 'timeout_ms'         BLOCK      FAIL     0/4        <- breaking by schema, 0 calls touched
C6 desc rewrite + added optional 'cache' BLOCK      PASS     0/4
------------------------------------------------------------------------------
byte-pin fired: 5/6   |   compat-gate fired: 3/6

Read the absolute counts, not the ratio. On this corpus of six changes, the byte-pin fired five times and the gate fired three. The two changes the pin flagged and the gate stayed silent on (C2, the real MCP additive; C6, a description rewrite plus an added optional cache param) were both backward-compatible: every recorded call still validated. That gap of two is the false alarms the gate removed on this corpus. It is not a claim about all possible changes; it is the measured difference on these six.

Two is a small number because six is a small corpus. But the shape scales the wrong way for the pin. The more a spec re-versions, the more the pin fires, and the fraction of those fires that are actually compatible does not drop just because the volume went up. A pin under heavy re-versioning is a siren that is right about “changed” and useless about “should I stop.” The gate’s job is to turn most of that siren off without turning off the part that matters.

Where does the compatible/breaking line actually sit?

I would rather you trust the small claim than the big one, so here is where this gets soft.

Replay only covers the calls you recorded. The gate is exactly as good as your call log. If your agent has a code path that fires a call shape you have never captured, C5 is your whole life: the gate will say breaks=0 and be technically right and operationally blind. The classifier signal (A) is the hedge here, because it flags the schema narrowing even when your traffic misses it, which is precisely why I keep both signals and fail closed when they disagree.

Compatible-by-schema is not compatible-by-behavior. This is the same blind spot the pin has, one level up. A server can keep inputSchema byte-identical and change what the tool does behind it: return different data, hit a different backend. Nothing in this gate, and nothing in a hash, catches that. Contract compatibility is not runtime compatibility, and I am not claiming it is.

The validator is a subset. Six keywords. It does not model pattern, minimum, maxItems, nested $ref resolution, anyOf, or a dozen other JSON Schema features that can each carry a breaking change. On a schema that tightens a pattern or lowers a maximum, this tool will miss it, because it does not read those keywords. That is a scoping decision, not a completeness claim. Extend the validator and you extend the coverage; the design leaves room for it.

What this is NOT

  • It is not a replacement for the pin. Run both. The hash catches the description-poisoning and rug-pull shapes, where a byte-level change is the signal and “compatible” is the wrong frame entirely: a hidden instruction slipped into a description is backward-compatible by subtyping and still hostile. The pin is your detector for “changed at all.” This gate is your filter for “changed in a way that breaks my calls.” They answer different questions and they belong in the same pipeline.
  • It is not a semantic scanner. It does not read the meaning of a description or judge whether a new parameter is dangerous. It asks one structural question about the argument set.
  • The breaking fixtures are synthetic. C3 to C5 are fixtures I wrote. They are confirmed by independent replay (a call valid under old genuinely fails under new, and the tool prints the failing path), not by my say-so, but they are not a live server. The one real diff is C2, and I checksummed it.
  • A PASS is not a proof of full compatibility. It means: on the six schema keywords I model, zero breaking changes, and zero of your recorded calls broke. It does not mean nothing can go wrong. See the section above.

Run it against your noisiest server

Pull the tools/list for the MCP server your agent leans on hardest. Save the inputSchema you have now, save the next version when the server updates, dump a few hundred of your real recorded calls to a .jsonl, and run compat_gate.py gate. If it passes, you just re-approved an update without a human reading a diff at 3am. If it fails, it hands you the exact calls that would have broken, before the first one goes out. That is the cheapest pre-call check you will add this quarter, and it runs before the call instead of in the postmortem.

Here is the part I still have not settled, and I want your take. Signal (A) and signal (B) disagree in the C5 shape (breaking schema, untouched traffic), and I resolved it by failing closed with a loud annotation. I am not sure that is right for every team. If you re-version aggressively, C5 is going to fire constantly on parameters nobody sends, and constant firing is how a gate gets disabled. Do you gate on the schema signal (conservative, noisy) or the replay signal (permissive, blind to unseen paths)? Do you tie re-approval to a publisher signature and skip the diff entirely? I have run this against exactly one real diff and a pile of fixtures, so my line is drawn in pencil. If you have run a compat check against a server that updates for real, tell me where you put the line and why. I read every comment.

If this was useful, the MCP tool pin is the layer above it, the pre-execution gate is the layer that decides whether a specific action runs at all, and the MCP server token tax measures what each tool costs you in context. Follow along; the next one keeps walking down this stack.