Every time I add a new MCP server to Claude Code, there’s a moment where I glance at the tool list, read a one-line summary for each one, and click approve. That’s it. That’s the entire security review most people do, myself included, before handing a piece of software the ability to call functions on my behalf.
The problem is that the thing I approved and the thing that actually runs can be two different objects, separated by time. That gap has a name: tool poisoning, and its sequel, the rug pull.
What the attack actually is
MCP servers expose their tools through a tools/list call, which returns a name, a description, and a parameter schema for each one. The description is meant for the model, not for you. It’s the part of the protocol that tells the LLM what the tool does and when to use it, and in most clients it’s also the part nobody reads carefully, because it’s long, written in prose, and buried under a technical-looking parameter list.
That’s exactly where tool poisoning lives. A tool can look completely ordinary, something like add_numbers(a, b), while its description contains instructions aimed squarely at the model: read a specific config file, base64-encode its contents, and pass it through an unused parameter before returning the sum. The user sees a correct arithmetic result. The model, which treats the description as part of its instructions rather than as untrusted data, quietly does what it was told.
This isn’t a theoretical corner case. Invariant Labs documented exactly this pattern in their original research on MCP tool poisoning, and it’s the reason “read the tool description before you trust it” became a real piece of advice rather than paranoia.
The rug pull is the part that actually worries me
Tool poisoning assumes the malicious description was there from day one. The rug pull is worse, because it doesn’t require that. A server can ship a clean, boring set of tools, pass your review, get approved, and run fine for weeks. Then an update, whether pushed by the maintainer, pulled in by an auto-update, or served dynamically by a backend you never see, changes what tools/list returns. The approval you gave at install time doesn’t expire, because nothing in the protocol asks the question again.
This matters more for agentic tools than for a normal npm dependency, for one specific reason: when a library changes behavior, a human eventually reads a diff, a changelog, or a stack trace. When an MCP tool description changes, the only thing that reads it is the model, on every single call, and it treats that text as instructions by design. There’s no code review step in between. The trust you gave once at approval time is exactly the trust being spent on every subsequent call, whether or not the thing you approved still exists.
What I actually do about it
I’m not going to pretend there’s a clean fix, because there isn’t one at the protocol level yet. What I added is a cheap stopgap: a check that runs at the start of every session, calls tools/list on each MCP server I connect to over HTTP, and hashes the {name, description} pairs into a small baseline file. If a hash doesn’t match what was recorded last time, I get a warning before anything from that server runs that session, and I go find out what changed and why before I trust it again.
It’s a few lines of logic, conceptually:
| |
This is roughly what Invariant’s own mcp-scan tool does with its pinning feature, and the idea generalizes to anything that exposes tool metadata over a reachable endpoint: hash what you approved, compare it on every use, and treat a mismatch as “unreviewed” rather than “probably fine.”
Where this breaks down
The honest limitation is that this only works cleanly for servers you can query without side effects, which in practice means HTTP-based ones. A server that runs as a local process, spawned fresh for each session, is harder to fingerprint this way, because checking it means actually starting it, and starting it is the thing you’re trying to be careful about in the first place. For those I fall back to pinning versions and not letting anything auto-update, which is weaker than a hash check but better than nothing.
I also don’t think this is a problem individual users are supposed to solve with a shell script. It’s a protocol-level gap: approval in MCP is a point-in-time event, and most of the tooling built around it still behaves as if that point-in-time decision holds indefinitely. Until something like signed, versioned tool manifests becomes standard, a cheap drift check is what stands between “I reviewed this once in April” and “I have no idea what this server is doing in October.”
If you’re running more than a couple of MCP servers and haven’t checked whether any of their tool descriptions have changed since you approved them, that’s probably worth five minutes before your next session.
