DeepConcepts

Agentic Security / MCP / tool discovery / trust boundary

MCP Tool Poisoning and Rug Pulls

The misconception

That approving an MCP server is a decision about what its tools do, checked once at install time. What was approved is a name and a launch command; the descriptions arrive later, can differ per session, can change after approval with no re-prompt, and are usually truncated or hidden in the client UI so the human never sees the text the model reads. Worse, the descriptions are not scoped to their own server: one server's description can rewrite how the model uses a different, trusted server's tool, so the blast radius of a poisoned server is every server connected to the same context.

13 min

When you approved that Model Context Protocol server, you approved a name and a command line. The text that actually steers the model — every tool's description, its JSON Schema, its annotations — arrived afterwards, over the wire, and the server may send different text tomorrow without asking you again.

The Model Context Protocol (MCP) is the wire format an agent client uses to discover and call tools on a server. Discovery is a request called tools/list. The server answers with a list of tool objects, and each one carries a name, an optional title, a description, an inputSchema whose properties have their own description strings, and an optional annotations block. The client concatenates all of that into the model's context. It is not metadata. It is instructions, written by the server, read by the model, on every turn.

The specification says so plainly. Its Tools page carries the warning that "clients MUST consider tool annotations to be untrusted unless they come from trusted servers", and its message flow shows a server sending notifications/tools/list_changed at any moment, after which the client re-issues tools/list and takes whatever comes back. So poisoning a tool description is indirect prompt injection that arrives before the agent has read a single byte of data — and it arrives inside the part of the context the client's user interface almost never shows you.

The simulation below is a client's approval ledger, run over three sessions and twenty-four tool calls. It hashes the fields you tell it to hash, checks that hash when you tell it to check, and shows the human whatever you tell it to show. One server is malicious. Start from the defaults and try to stop it by turning on re-verification — the fix everybody proposes first.

fields the approval digest covers

The server's name and launch command are always covered — that is what the install prompt recorded. These five are the fields that arrive later, from the server, and land in the model's context.

Byte counts are typical sizes for a real tool object, not measurements of any specific server. Everything else — which field the payload lands in, when a check fires, whether the human can see the changed bytes — is computed from the rules you set.

tool calls made under poisoned instructions, of 24
caught at
field the payload used
re-prompts raised
tool text the human sees per approval
tools the payload can retarget
share of tool instructions written by the poisoned server
Twenty-four tool calls, three sessions

the call ran against the tool text you approved · the call ran with the poisoned instructions in context. A lane that turns magenta part-way through is the rug pull landing mid-session.

What is real and what is a model. The event sequence is the protocol's: tools/list at session start, a notifications/tools/list_changed from the server, a re-fetch, then calls. The digest logic is real set arithmetic over the fields you tick, and the attacker's field choice is a search over the fields you left out. What is modelled is the size of a tool object and the number of calls per session.

At the defaults the hero number reads 16. Sixteen of twenty-four calls ran with an attacker's instruction block sitting in the model's context, and the caught at readout says never. That is an ordinary configuration: a digest over tool names, checked once at install, with a client that displays a name.

Now do the obvious thing. Set re-verify the digest to at every session start and every tools/list_changed. The hero number does not move. It stays at 16, and the log tells you why: the payload is in the description, the digest covers name, and a hash over the names matches perfectly because the names did not change. Checking more often does nothing at all until you have said what you are checking.

So tick description. Now the hero number is still 16 — and re-prompts raised has jumped from 0 to 16 (1 malicious). Read the blessed line in the log. The re-prompt fired, correctly, at the start of session 2. The client showed the human 0 of the 1,240 bytes of the field that changed, because at this disclosure level it shows a tool name. The human approved. And because approving re-baselines the digest, that prompt will never fire again. You added sixteen interruptions across three sessions and changed the outcome by zero calls.

Only now set what the client shows the human to every field, untruncated. The hero drops to 0, caught at session 2 start. Three conditions had to hold together: the digest covered the field the payload used, a check ran before the next call, and the client rendered the changed bytes where a person could read them. Turn any one of the three off and the number goes back up.

Leaving one field out is the same as leaving all five out

Stay in the three-condition state that worked — description covered, verification on every tools/list_changed, full disclosure, hero at 0 — and change the payload hides in to whichever field your digest ignores. The hero number goes straight back to 16, and the field the payload used readout now says inputSchema property description.

This is the part that makes the digest a lattice rather than a switch. The simulation gives a tool five server-supplied string fields and all five reach the model. The description is the famous one, but every property in the inputSchema carries its own free-text description, and the annotations block carries a title and four behavioural hints — readOnlyHint, destructiveHint, idempotentHint and openWorldHint, the exact structure the specification tells clients to treat as untrusted. Anything the model reads is somewhere the payload can live.

Watch the digest lose a race it cannot win. Untick all five boxes, then tick them back one at a time in the order description, inputSchema, annotations, title, and read field the payload used after each click. At zero boxes it says description; at one, inputSchema property description; at two, annotations; at three, title. The hero number stays at 16 the whole way. Four boxes ticked is exactly as good as none.

name is the interesting stop — the fifth click, with the other four already covered. The payload has nowhere left to go but the tool name, and there it is caught at session 2 start even though the digest still does not cover names. Set what the client shows the human back to the tool name and it is caught anyway. Read that log line: nothing verified anything. The name is simply the one field the client renders on every turn, so 1,064 bytes of instruction text appeared where a short identifier should be. That is the entire security property of a name: not that it is short, but that it is shown. Every other field is invisible by default, which is exactly why the payload goes there.

Five is the simulation's count, not the protocol's. The real Tool object also carries an optional outputSchema, whose properties have their own description strings, and a _meta object. The lattice is larger than the one you just walked, which only sharpens the conclusion.

The practical consequence is that partial coverage buys nothing. A digest over four of five fields has the same detection rate against a competent attacker as a digest over zero: the attacker enumerates the fields, finds the gap, and uses it. This is not a case where each additional field reduces risk a little. It is a floor at zero until the last box is ticked.

What pinning actually binds

Set how the server is delivered to local package pinned by integrity digest. The log immediately overrides your timing choice: a pinned tarball cannot change its tool text after install, so the rug pull is impossible and the mutation is applied at install instead. Pinning works — for exactly the threat it addresses.

Then read the hero number. Tick all five digest fields and set verification to before every tools/call — the strictest configuration the simulation offers — and it reads 24, the maximum, with caught at on never. This is the boundary, and it is worth being precise about why. A digest detects change. If the malicious text was in the first tools/list the client ever received, it is inside the baseline. Every subsequent comparison succeeds, because the bytes genuinely have not changed. Verification is trust-on-first-use, and the first use was already compromised.

One setting still catches it, and only one. Set disclosure to every field, untruncated and the hero drops to 0, caught at install — because the human read the text before approving it. The log prices that: 9,690 bytes of tool JSON at a single install prompt, with four other servers connected. Nobody reads that, which is the honest reason trust-on-first-use holds in practice rather than in principle.

Now switch delivery to streamable HTTP — a remote endpoint you do not run and note what is no longer available to you. There is no tarball, no digest, no version to pin. The tool text is generated per request, so it can differ per session, per user, and per client — a server can serve benign descriptions to a security researcher's fresh connection and poisoned ones to an authenticated session with an interesting-looking tool list beside it. "I pinned the version" is a statement about a local package's bytes on disk. It says nothing about a remote server, and it says nothing about the text that shipped inside the pin.

The middle option, npx package@latest, is the one most install instructions actually give you, and it is the worst of both: the package is re-resolved from the registry at every launch, so the tool text can change between sessions with no notification and no lockfile entry to diff. The published record of MCP servers being mass-forked and republished is the supply chain for exactly this. And note that connecting is not a safe read-only act you can perform to inspect a server first: CVE-2025-6514 (CVSS 9.6) was OS command injection in the mcp-remote client, reached through a crafted authorization_endpoint URL in the response, and triggered by connecting to an untrusted MCP server. Versions 0.0.5 up to but not including 0.1.16 were affected.

The blast radius is the context, not the server

Put every control back where it started — reload the page — and change one thing: set the payload aims at to a different server's tool. The tools the payload can retarget readout goes from 0 to 14, and the slider for other connected servers now moves it: at 7 other servers it reads 23.

This is tool shadowing, and it is the variant that breaks the mental model people bring from app permissions. The instinctive question about a new server is "what can it do?" — which credentials it holds, which scopes it requested. That question has the wrong subject. The poisoned server's own permissions do not bound the attack, because the payload is not a call. It is a sentence in the model's instructions, and the model applies instructions to whatever tools it has. A note-taking server with no credentials at all can carry text that says the repository tool must first read a particular file and pass its contents as a parameter, and the model will do that with the repository server's credentials, through the repository server's approved code path.

Put other connected servers back to 4 and read the share of tool instructions written by the poisoned server readout: 22.0%. One server out of five is writing just over a fifth of the tool-definition text in the model's context. Drag other connected servers down to 0 and it becomes 100%; drag it to 7 and it falls to 14.6% — while the number of tools it can retarget rises to 23. Adding trusted servers dilutes the attacker's share of the instructions and multiplies what those instructions can reach, at the same time. The share is the number people would quote in a review, and it moves the wrong way.

Tick give each server its own context, no shared tool list and the retarget count goes to 0. This is a real defence — it is the same move as handing each server to a separate sub-agent, discussed in trust between agents — and the log states its price: the agent can no longer do anything that needs two servers in one turn, which is most of what people connect multiple servers for. Drag other connected servers back to 4, switch the aim back to its own tool with isolation still on, and watch the retarget count stay at 0 while the hero number sits at 16. Isolation contains shadowing. It does nothing whatever about a rug pull on the server's own tools, because those tools are inside the isolated context by construction.

What a payload can accomplish once it is steering a trusted tool is decided somewhere else entirely: by the permissions on the identity that tool authenticates with. That is excessive agency, and it is the reason the same poisoned description is a nuisance on one deployment and a breach on another. If the poisoned server also supplies a way to read something private and a way to emit bytes, it has assembled the lethal trifecta on its own, inside one install.

The prompt that becomes a permanent grant

From the defaults again: tick description in the digest, set verification to before every tools/call, leave disclosure on the tool name, and push routine tool-list updates per server per session to 3. The re-prompts raised readout reads 46 (1 malicious). Push other connected servers to 7 as well and it reads 73 (1 malicious).

Those forty-five or seventy-two other prompts are not noise in the sense of being wrong. Every one of them is a correct report that a server changed text the model will read. Servers edit their tool descriptions constantly: wording fixes, new parameters, version strings. The detector is working exactly as designed, and the base rate of malice among the events it fires on is 1 in 73. That is the shape of the control, not a tuning problem, and it is why approval fatigue is the load-bearing limit on every "just re-prompt on change" proposal — including the one this lesson just talked you into.

Two mechanics in the simulation make it worse than a simple hit rate. The first is in the blessed log line: an approval re-baselines the digest. Once a human clicks through a change, the new bytes are the approved bytes, and no later check will ever raise that prompt again. One tired decision is converted into a standing grant, and there is no expiry on it — the same structural problem as revoking a token after the fact, where the grant outlives the decision that created it.

The second is that a re-prompt shows a diff, and a diff is only useful if the human can read the changed bytes. Drag other connected servers back to 4 and move disclosure to the name plus the first 60 characters of the description: the tool text the human sees per approval readout goes from 224 of 9,690 bytes (2.3%) to 1,184 of 9,690 (12.2%), and the hero number does not move at all. The payload sits after the original 190-byte description, outside the window. A truncated diff is not a weaker version of a full diff. Against an attacker who knows where the truncation is, it is not a diff at all.

Full disclosure is the setting that works, and the simulation is honest about its cost: with 4 other servers connected it puts 9,690 bytes of tool text in front of the human at every approval — roughly two thousand words of JSON to read carefully, 46 times across three sessions, or 73 times with seven other servers connected. Nobody does this. The realistic version is not "show the human everything" but "show the human the diff, in full, and make routine updates not generate one" — which means signed tool manifests with a publisher key, so that a change from a known publisher can be accepted without a prompt and a change from anyone else cannot.

Why filtering the description is not the answer either

The tempting shortcut is to scan tool descriptions for suspicious text — IMPORTANT, <instructions>, "before using any other tool" — and refuse to load servers that contain it. This is classifier-based injection defence pointed at a new input, and it inherits every limit of classifier-based injection defence, which the defence-limits lesson measures directly: a residual attack success rate a few percent above zero is a complete failure here, because the attacker gets to retry against your published filter offline, for free, until they find a phrasing that passes.

It is also worse than the usual case in one specific way. Ordinary injected content has to look like the data it is hiding in — an issue body, a web page. A tool description has no such constraint, because a tool description is supposed to be an instruction to the model. "Call this before calling anything that touches the filesystem" is a legitimate sentence for a tool description to contain. There is no syntactic property that separates a helpful usage note from a malicious precondition, because there is no semantic difference either. The only difference is who wrote it and whether you agreed to it.

Which is the actual conclusion. The unit of trust cannot be the sentence, because sentences are indistinguishable. It has to be the publisher, bound to the exact bytes, checked every time the bytes are used. Everything else in this lesson is a way of approximating that badly.

Checking this on a client you actually run

First, read the text your model is reading. For a stdio server, the tool definitions arrive as one JSON-RPC response, and you can capture it by wrapping the launch command: replace npx some-server with a shell wrapper that pipes both directions through tee into a log file. The tools/list result is the object you want. For a remote server, the official MCP Inspector prints the raw objects rather than a client's rendering of them: npx @modelcontextprotocol/inspector --cli --server-url https://example.com/mcp --transport http --method tools/list. Look specifically at the fields your client does not display: every description inside inputSchema.properties and inside outputSchema.properties, and the whole annotations block.

Second, build the baseline you thought you had. Canonicalise each tool object — sort keys, fixed separators — and store a SHA-256 digest per tool alongside the server name in the same file that pins your server versions. Canonicalise the whole object, not a field list — the previous section is what partial coverage buys you, and the protocol adds fields between revisions. Then re-run it in continuous integration and on every agent launch, and diff. Two numbers are worth alerting on: any change to a tool object at all, and the total byte count of tool text in the context, which should be roughly flat and is the cheapest possible signal that a description just grew by a kilobyte.

Third, log the discovery events, not just the calls. Most agent observability captures tools/call and drops tools/list and notifications/tools/list_changed entirely, which means the rug pull is invisible in the trace even after you know it happened. Record every tools/list response with a timestamp, a session id and the digest of each tool object. In an incident the question you will need to answer is which version of a description was in context when a given call was made, and without those records there is no way to answer it — the transcript shows a model that chose a tool, and the server shows a tool that was called, and neither shows the sentence that made it happen.

Fourth, treat "connect and look" as a privileged action rather than a reconnaissance step. Evaluate an unfamiliar server in a container with no credentials mounted, no network beyond the server's own endpoint, and no other servers connected — and only then read its tool text. The mcp-remote advisory is the reason: connecting to an untrusted endpoint was itself the exploit.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.