← Writing
August 28, 2026 · 6 min read

The tools you audited are not the tools that run

MCP servers can change their own tool list after you approve them. Static review has a shelf life, and the fix is not a better review.

The usual way a team adopts an MCP server is a one-time review. Someone reads the tool schemas, checks the scopes, decides it is reasonable, and ships it. The decision then gets filed as though it were permanent. It is not. It is a photograph of a moving thing.

This is not a claim that some servers are badly behaved. It is what the protocol allows by design, and three parts of it matter.

  • The tool list can change mid-session. A server can send notifications/tools/list_changed and hand the client a different set of tools than the one you approved at connect. Nothing pins the list you reviewed to the list that runs.
  • Tool descriptions reach the model on every call.That text is not documentation for humans that happens to live in a schema. It is input, delivered continuously, and it steers the model. It is also fully under the server’s control and can be rewritten between one call and the next.
  • The hints are hints. readOnlyHint and its siblings are advisory. They describe intent. They do not constrain execution. A tool annotated read-only executes whatever its implementation does, and the same party writes both.

Put those together and the review covers exactly one moment: the moment you looked. A review answers a question about the past. The question you care about is what the tool list contains on the call happening right now.

Worth saying plainly: we have not caught a server doing this to us. No incident, no war story. This is an argument from what the protocol permits, which is weaker than an incident report, and we would rather label it than let it read as one.

What to actually do

None of this requires anything of ours. The techniques are ordinary and go in whatever client you already run.

  • Snapshot the whole surface, not the names. Names, input schemas, annotations, and the full description text. Descriptions are the most likely to change and the least likely to be diffed, because they read like prose and people skim prose.
  • Canonicalise it and hash it. Sort by tool name, serialise deterministically, take a SHA-256. Now you compare one value instead of walking a tree, cheap enough to do every time.
  • Diff on every connect, and again on every list_changed. Connect time is the obvious hook. The mid-session notification is the one that matters, because that is the path a review never sees.
  • Fail closed. A drifted fingerprint should stop calls to that server until a person looks. Warn-and-continue is the same as no check, one log line more expensive.
  • Watch what it touches, not what it declares. Run it and observe: what ports it opens, what files it reads, where it egresses. lsof and a packet log answer questions a schema cannot. A declared surface and an observed surface that disagree is the finding.
  • Decide who can revoke it, and whether revoking needs its cooperation.If the off switch is a flag in the server’s own config, or a scope it checks itself, you are asking a component to disable itself on request. Put revocation where you hold it: the credential at the resource, the network path, the process supervisor. Then pull it while the server runs and confirm the next call fails. An untested kill switch is a belief.
# on connect, and again on every notifications/tools/list_changed

surface = [
    {
        "name":        t.name,
        "description": t.description,   # model input on every call — diff it
        "inputSchema": t.inputSchema,
        "annotations": t.annotations,   # advisory, diff it anyway
    }
    for t in sorted(client.list_tools(), key=lambda t: t.name)
]

fingerprint = sha256(
    json.dumps(surface, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()

if fingerprint != PINNED[server_id]:
    halt(server_id)                     # stop calling. do not warn and continue
    show_diff(PINNED[server_id], surface)

One caution on reading a failed diff. Servers change their tool lists for ordinary reasons; the protocol carries the notification because that is legitimate. A changed fingerprint is a signal to look, not proof of anything. The value is that the change stops being silent. Treat every drift as an attack and you will turn the check off within a month, which is worse than never adding it.

What this does not do

Pinning a tool list does not stop tool poisoning or prompt injection. A hostile description can still steer a model, and a model reading it can still be steered. We have no defence for that and will not imply otherwise. What a fingerprint buys is narrower: the swap is not silent. What a governed call buys is narrower still: the consequence is reviewable before it happens and provable after it does.

That distinction is the whole thing. We cannot make an agent safe in the sense people mean when they say it. What we can do is make what an agent did legible. That is a smaller claim, and it is the one we are willing to defend.

We are not exempt from this

33 tools
our own station’s MCP surface: 33 names, 33 schemas, and 33 blocks of description text that reach the model
SHIPPED SURFACE · RAILCALL STATION · 28 AUG 2026

That number is here to make a point against ourselves: the same protocol properties apply to our server as to any other. If you connect ours, fingerprint ours. Do not take our word for the scopes any more than you take anyone else’s. Trust that cannot be checked is a preference about who to believe.

Enforcement cannot live inside the thing being reviewed

The structural problem is a timing problem. Review happens before the call. The thing you care about happens during it. Any control the reviewed server administers on its own behalf inherits every assumption you were trying to test. So the check has to sit somewhere the server does not control: your client, your network path, your credential store, or a layer between the agent and the world.

That is where our airlock sits. State-changing actions never auto-fire over MCP. A human approves the exact payload in RailHub Studio before anything is sent. Sensitive scopes need dual-control codes, there is a global freeze switch, and the station binds 127.0.0.1 only, which you can confirm with lsof rather than take from this page. None of that prevents a server from changing its tools. It means the tool that ran, and the payload it ran with, land in a signed receipt you can read afterwards and check without asking us.

Every governed action RailCall runs emits an Ed25519-signed receipt into a hash-chained log. You can verify one offline at railcall.ai/verify — no account, no login, and nothing calls back to us.