The MCP Tool You Approved Last Week Is Not the One Running Today
You approved an MCP server once, and your agent has trusted its tools ever since. But the spec lets a server change its tools/list response between sessions with no re-approval and no integrity check, so the friendly tool you vetted on Monday can ship a poisoned description on Friday. Here is how to fingerprint every tool definition at approval time and gate the agent on drift before the changed tool ever runs.
You approved an MCP server once. Someone on your team added it to the config, the client fetched its tools/list, a dialog asked whether to trust these tools, you clicked yes, and your agent has been calling them ever since. That approval is the last time anyone looked at what those tools actually claim to do.
Here is the uncomfortable part. The Model Context Protocol lets a server return a different tools/list on the next connection, with a changed description, a widened input schema, or a renamed tool, and the spec requires no re-approval and provides no integrity check. The client does not re-prompt, because in the user's mind approval was granted for "this server," not for "this exact set of definitions." So the friendly get_fact_of_the_day tool you vetted on Monday can ship a description on Friday that quietly instructs your agent to read a customer's chat history and paste it into an argument, and nothing in your stack notices.
This is called a rug pull, and it is not theoretical. The Invariant Labs proof of concept is a plain "random fact of the day" server that behaves normally on first launch, then on a later launch replaces its tools/list with a poisoned description that redirects the agent to misuse a legitimate WhatsApp MCP server and exfiltrate chat history. OWASP has since catalogued this as MCP03 in its MCP Top 10 and as ASI04 in the Agentic Top 10, and more than thirty CVEs were filed against MCP servers and clients in the first two months of 2026. The MCP supply chain is now an active attack surface, and the thing that makes it dangerous is boring: your client trusts a definition it fetched once and never checks again.
The business problem: trust granted once, exercised forever
Every MCP integration has a moment where a human decides a tool is safe. That decision is expensive to make well. Someone reads the tool description, thinks about what the agent might do with it, maybe checks the server's reputation, and signs off. It is exactly the kind of judgment you do not want to repeat on every single agent run, so clients cache it. The problem is that the thing you approved is not pinned to the thing you run. You approved a tool as it was described at 9am on Monday. You are running whatever the server chooses to describe right now.
For a personal assistant that is a privacy incident. For an agent wired into your production systems it is worse. The tools an agent trusts are the tools that move money, delete records, send email, and touch customer data, because those are the integrations worth building. A rug pull turns one of those trusted tools into an instruction channel that the model obeys with full authority, and it does it inside the highest-trust region of the context, the tool definitions themselves. This is the same capability-supply-chain risk I wrote about for third-party agent skills, except here you do not even have to install anything new. The server you already trusted changes underneath you.
The reason your existing defenses miss it is that they watch the wrong surface. Output guardrails scan what a tool returns. Prompt-injection filters scan the data an agent processes. A rug pull lives in the tool definition, which most clients treat as configuration rather than untrusted input. So the poisoned text sails straight past the layer you built to catch poisoned text, because you never pointed that layer at the definitions.
The fix: pin the definition, verify on every session
The mechanism is simple and it mirrors how you already handle dependencies. When someone approves a tool, you capture a fingerprint of exactly what they approved. Before the agent is allowed to use that tool again, you recompute the fingerprint from the current definition and compare. If they match, proceed. If they differ, the tool is not the one that was approved, so you stop and route it back to a human. It is package-lock.json for tool definitions.
Start with the fingerprint. Hash the fields the model actually reads to decide behavior: the tool name, the full description, and the input schema. Serialize them canonically so cosmetic formatting changes do not trip a false alarm.
import hashlib
import json
from typing import Any
# The three fields a server controls and the model actually reads. An attacker
# changes one of these to redefine behavior, widen an argument, or shadow
# another server's tool. Everything else is metadata we can ignore.
def fingerprint_tool(tool: dict[str, Any]) -> str:
canonical = {
"name": tool.get("name", ""),
"description": tool.get("description", ""),
# inputSchema is a nested dict; sort_keys makes reordered properties
# hash identically so we only flag real changes, not JSON churn.
"input_schema": tool.get("inputSchema", {}),
}
blob = json.dumps(canonical, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
The sort_keys=True is doing real work. A server can legitimately return the same schema with its properties object in a different order, and you do not want that to read as an attack. Canonicalizing before hashing means you only flag a change in what a field says, not in how it was formatted. That keeps your false-positive rate low, which matters, because a drift detector that cries wolf gets disabled within a week.
Now capture the baseline at approval time. This is the one moment a human is in the loop, so it is the only moment you are allowed to write a trusted fingerprint.
import time
# Persist this to a real store (DB, KV, or a committed JSON file). The key is
# scoped per server so an identical tool name on two servers cannot collide,
# which is also how you defend against tool shadowing.
def approve_server_tools(server_id: str, tools: list[dict], store) -> None:
baseline = {
tool["name"]: {
"fingerprint": fingerprint_tool(tool),
"approved_at": time.time(),
# Keep the raw description so a human reviewing drift later can see
# a side-by-side diff, not just "the hash changed".
"description": tool.get("description", ""),
}
for tool in tools
}
store.put(f"mcp:baseline:{server_id}", baseline)
Two details there earn their place. Scoping the baseline per server_id is what stops tool shadowing, where a malicious server declares a tool with the same name as a trusted one and hopes your agent calls the wrong version. And storing the raw description alongside the hash means that when drift fires at 2am, the human who reviews it sees the actual old and new text, not a pair of hex strings.
The gate: no fingerprint match, no call
The check has to run before the tool executes, and it has to be the only path to execution. If the agent can reach a tool without passing this gate, the gate is decoration. Put it at the same chokepoint where you already resolve a tool call to allow or deny, the one I described for action authorization as policy-as-code. Drift is just another reason the answer is deny.
class ToolDriftError(Exception):
def __init__(self, server_id: str, tool_name: str):
self.server_id = server_id
self.tool_name = tool_name
super().__init__(f"drift on {server_id}/{tool_name}")
# Called with the tool definition as the server presents it RIGHT NOW, freshly
# fetched this session, not a cached copy from approval time.
def verify_before_call(server_id: str, live_tool: dict, store) -> None:
baseline = store.get(f"mcp:baseline:{server_id}") or {}
record = baseline.get(live_tool["name"])
# Not in the baseline at all: a tool that appeared after approval. Default
# deny. A new tool is a new decision, not an automatic yes.
if record is None:
raise ToolDriftError(server_id, live_tool["name"])
if fingerprint_tool(live_tool) != record["fingerprint"]:
# The definition changed since a human approved it. Stop here.
raise ToolDriftError(server_id, live_tool["name"])
The unknown-tool case is the one people forget. A rug pull does not have to mutate an existing tool. It can add a brand new one that was not present at approval, and if your check only compares tools you already have baselines for, the new tool slips through with no baseline to fail against. Defaulting to deny on any tool that is not in the approved set closes that door. New capability, new approval, no exceptions.
Wire it into the executor so there is no way around it:
async def call_tool(server_id: str, tool_name: str, args: dict, session, store):
# Refetch the live definition this session. This is the whole point: you are
# checking what the server claims NOW, not what you cached when you connected.
live_defs = {t["name"]: t for t in await session.list_tools()}
live_tool = live_defs.get(tool_name)
if live_tool is None:
raise ToolDriftError(server_id, tool_name)
verify_before_call(server_id, live_tool, store) # raises on drift
return await session.call_tool(tool_name, args) # only runs if verified
When ToolDriftError fires, do not throw the run into a crash and do not silently swap in the old definition. Route the drifted tool to a human re-approval queue with the old and new descriptions side by side, exactly the human-in-the-loop approval gate you use for other irreversible decisions. A person looks, decides whether the change is a benign version bump or an attack, and either writes a fresh baseline or pulls the server. The agent keeps running everything else in the meantime, because the gate is per tool, not per server.
Tradeoffs and pitfalls
Fingerprinting cannot catch a server that was malicious from the first fetch. There is no clean baseline to compare against, so a tool that shipped poisoned on day one hashes fine forever. Drift detection is a tripwire for change, not a proof of safety. It belongs behind an allowlist of servers you chose deliberately and in front of a default-deny policy on what any tool is allowed to do, so that even an approved tool cannot exceed the permissions you granted it. The prompt-injection defenses you already run on tool outputs still apply, because a verified tool can still return hostile data.
You have to actually refetch every session. The entire attack exploits the gap between approval and use, so a client that fingerprints once at approval and never re-lists has built a lock with no one checking it. This is easier to get right on a stateless MCP setup, where every request already carries fresh context and you are not leaning on a long-held session that fetched tools once at handshake and cached them.
Tune for real change, not JSON churn. If your detector fires on whitespace or key ordering, people will approve their way through the alerts on autopilot and miss the one that matters. Canonicalize hard, and consider treating a description change and a schema change differently, since a widened inputSchema that turns a narrow path argument into "any string" is a stronger signal than a reworded sentence. Log the diff either way so the review is a five-second glance, not an investigation.
Signatures are the stronger version of this. Fingerprinting proves the definition did not change since you approved it. It does not prove the definition came from who you think. The direction the ecosystem is moving, and what the ETDI work argues for, is immutable versioned tool definitions signed by the publisher, so you verify a signature instead of trusting whatever bytes arrived on the wire. Until your servers support that, fingerprinting is the pragmatic floor, and it is a floor you can ship this week.
The takeaway
MCP made it trivial to hand your agent new capabilities, and it made trust flow the wrong way by default: your client inherits it from a server once and never checks again. A rug pull is just someone noticing that the thing you approved and the thing you run are not the same object. The fix is not exotic. Pin what a human approved by fingerprinting the name, description, and schema, refetch and re-verify on every session, default to deny on anything you have not seen, and route drift to a person instead of a crash. It is a lockfile and a diff, applied to the part of your agent you have been treating as configuration when it is really untrusted input.
If you are wiring MCP servers into an agent that touches anything you would not want in a stranger's hands, and you are not sure which of your tools are pinned versus trusted-on-faith, book a consultation call and we can map your tool trust boundary before a server you approved last week decides to change its mind.
Viral Ruparel
Generative AI consultant helping teams ship reliable LLM and agent systems in production.
Contact Viral about your AI project →Frequently Asked Questions
What is an MCP rug pull?+
A rug pull is when an MCP server changes a tool's definition after you have already approved it. The MCP spec allows a server's tools/list response to change between calls with no required re-approval and no integrity check, so a server can serve you a benign tool description at approval time and a poisoned one later. The agent reads the new description as instructions and acts on them, which is how a tool you vetted becomes a data-exfiltration path without anyone clicking approve a second time.
How is a rug pull different from ordinary prompt injection?+
Ordinary prompt injection arrives in data the agent processes, like a web page or a document. A rug pull hides in the tool definition itself, the description and input schema the model reads to decide how to call a tool. Because most clients fetch tool definitions once and trust them for the life of the connection, the injected text sits in the highest-trust part of the context and never gets re-checked. The defense is different too: you detect it by comparing tool definitions across sessions, not by scanning tool outputs.
Which fields should I fingerprint to detect tool drift?+
Hash the server-controlled fields the model actually reads: the tool name or title, the full description, and the input schema. Those are the three fields an attacker changes to redefine behavior, widen an argument, or shadow another server's tool. Serialize them canonically with sorted keys so formatting changes do not create false positives, then hash the result. Do not hash volatile fields the server may reorder harmlessly, and do canonicalize the schema so a reordered properties object does not read as drift.
Does fingerprinting stop every MCP supply-chain attack?+
No. Fingerprinting catches a definition that changes after you trusted it. It cannot catch a server that was malicious from the very first fetch, because there is no clean baseline to compare against. So drift detection is one layer. You still want an allowlist of servers, a default-deny policy on tool calls, human approval on destructive operations, and vendored or signed definitions for the few tools that touch money or customer data.
Related Articles
Your Agent Fails the Same Way Every Week and Learns Nothing
Your agent trips over the same edge case every Monday, you patch the prompt by hand, and next Monday it trips again. Fine-tuning is slow and expensive, and a naive memory that summarizes everything quietly erases the details that mattered. Agentic context engineering is the middle path: let the agent evolve a living playbook from its own execution feedback, with a Generator, Reflector, and Curator that add small deltas instead of rewriting the whole thing.
Your AI Agent Shares One API Key. That Is the Problem.
A recent survey found 93% of AI agent projects still authenticate with an unscoped API key, and most agents end up with more access than they need. That single shared credential makes every action unattributable and turns one leak into a total breach. Here is how to give your agent its own workload identity and mint short-lived, delegated, audience-scoped tokens with OAuth token exchange instead.
MCP Went Stateless. Your Server Still Holds Sessions.
The 2026-07-28 MCP spec dropped the initialize handshake and the session id, which means any request can now land on any replica behind a plain load balancer. But only if your server stopped holding session state. Here is how to make an MCP server truly stateless, migrate held-open elicitation to Multi Round-Trip Requests, and route on the new headers.