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

> Source: <https://dev.to/alex_spinov/your-mcp-pin-blocks-every-update-most-never-broke-you-3g66>
> Published: 2026-07-24 03:47:33+00:00

A month ago I shipped a 40-line padlock for MCP tools: [pin the manifest hash, block the rug-pull](https://finops.spinov.online/blog/mcp-tool-pin-verify/). 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 arealdated 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.

Because a hash has one bit of memory: same, or different. The [June pin](https://finops.spinov.online/blog/mcp-tool-pin-verify/) 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](https://github.com/modelcontextprotocol/modelcontextprotocol) 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.

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:

`additionalProperties: false`

.`number`

to `integer`

throws out `3.5`

. Any incompatible type swap can strand an old value.`enum`

.`"csv"`

from `["json", "csv"]`

and every call that asked for CSV is gone.`additionalProperties`

from `true`

to `false`

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

`enum`

`integer`

to `number`

), 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.

`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:

``` python
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.

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.

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.

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.

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](https://finops.spinov.online/blog/mcp-tool-pin-verify/) is the layer above it, the [pre-execution gate](https://finops.spinov.online/blog/pre-execution-gate-for-ai-agents/) is the layer that decides whether a specific action runs at all, and the [MCP server token tax](https://finops.spinov.online/blog/mcp-server-token-tax/) measures what each tool costs you in context. Follow along; the next one keeps walking down this stack.
