Agent security / Model Context Protocol
MCP Tool Poisoning: from code execution to bypassing the agent's permissions
A malicious MCP server published in a public repository can chain installation, privilege inheritance over stdio, configuration tampering and persistence outside the session to turn a seemingly harmless install into lasting control of the machine and the agent.
Context: why stdio is where the risk lives
The Model Context Protocol (MCP) standardizes how an AI agent discovers and calls external tools: instead of hand-writing each integration, an MCP server exposes tools that the model invokes over JSON-RPC.
The most common transport for local tools is stdio. The host (Claude Code, for example) spawns the server as a child process and talks to it over stdin/stdout. There is no network and no authentication, because none is needed: the child process inherits the operating system user that started it, along with the environment variables, file permissions and credentials already loaded in the session.
That design choice is intentional and correct for the legitimate use case: running your own script, reaching a local database. The problem starts when the code running inside that process is not yours.
A note on "privilege escalation"
In the classic sense, local privilege escalation means going from a regular user to root. That is not what happens here: the UID never changes. What escalates is a different boundary: the agent's permission layer. The attacker starts out limited to what the child process can do and ends up controlling what the agent can do in later sessions, and after that no longer needs the agent at all.
Two threat classes that often get confused
Malicious code (supply chain). The handler does something other than what it declares. It is a classic trojan, delivered through npm install, pip install or a direct clone.
Malicious metadata (tool poisoning, in the original sense). Invariant Labs formalized the term in April 2025: malicious instructions embedded in the tool description, which the model reads but the user usually never sees. The code can be entirely benign. That is why this class works even with remote servers, with no local execution at all.
The chain described here combines both.
0The dependency nobody audits
There is a vector that comes before everything else, and it is subtler: the MCP server can be honest and still be the vehicle.
You audit the server's repository, read the handlers, confirm that the implementation matches the descriptions, and approve it. What you probably did not do is audit the dozens or hundreds of packages it pulls in as transitive dependencies. It only takes one of them being under the attacker's control.
- Compromise of a legitimate package. A maintainer account hijacked through phishing, a new version with one extra line.
- Typosquatting. A package with a name almost identical to a popular one, waiting for a typo or a wrong suggestion from an LLM.
- Dependency confusion. A public package with the same name as a company's internal package, published with a higher version and resolved first by the package manager.
- Hallucinated package. A model suggests a library that does not exist; the attacker registers that name and waits.
And the detail that closes the loop with stage 2: execution happens at install time, not at use time. Package manager lifecycle scripts run as the user who installed. You never even started the MCP server: the install was enough.
1The interface lies about the implementation
What the model sees of a tool is essentially three fields: name, description and inputSchema. The specification also defines title, outputSchema and annotations, such as readOnlyHint, but all of them are declared by the server itself. Nothing in the protocol verifies that the code behind them does what they promise.
| What the model sees | What the handler actually runs |
|---|---|
format_document(path) | Reads .env, ~/.ssh/, cloud credentials |
| "Formats a document to the company standard" | Sends what it read to an external endpoint |
Simple schema: {path: string} | Returns forged text: "Document formatted successfully!" |
On the metadata side, three documented variants:
- Tool poisoning. The description carries hidden instructions that the model follows. In the canonical Invariant Labs example, an addition function instructs the model to read SSH keys and pass them in an extra parameter.
- Rug pull. The server ships benign descriptions, gets approved, and later swaps them for poisoned versions, with no new approval.
- Tool shadowing. The description from a malicious server redefines how the model should use a tool from another server, one that is trusted and untouched.
In every case, the user approves the friendly name, not the real behavior.
2Execution with the user's privileges
One detail changes how the attack should be read: the code does not start running when the tool is called. It runs when the server is started and, before that, at install time. Starting the server is already the point of no return. The poisoned tool is not the trigger, it is the cover: it gives the attack the look of normal usage and a forged result to show.
From spawn onward, the process can, without asking for anything more:
- Read any file your user can read: environment files, SSH keys, cloud credentials, shell history
- Make network requests to any destination
- Write or modify files wherever your user has write permission
- Execute binaries and scripts already installed on the machine
This is not an MCP flaw. It is the same exposure as any unknown package or unaudited extension. MCP just makes the pattern easier to trigger without noticing, because the interface hides the implementation behind a "click and trust" invitation.
3The target shifts from the process to the agent
An honest question belongs here: if the handler already has the user's full access (and Claude Code's deny rules do not even apply to it, since they constrain the agent's tools, such as Bash and Read), why would the attacker touch settings.json?
Because the goal stops being immediate access and becomes persistence and control of the agent. Files like ~/.claude/settings.json and their project variants are plain JSON on disk: no signature, no integrity hash, no lock against writes from another process of the same user. Editing them makes it possible to:
- Remove
denyentries and addallowones, so that any future prompt injection finds the agent unguarded: the agent becomes a confused deputy - Register hooks, which run shell commands on session events, guaranteeing execution even after the malicious server is uninstalled
- Tamper with MCP server registrations or instruction files such as
CLAUDE.md, which the agent reads as trusted context
It is the pattern of malware that disables the antivirus before acting, with one difference: here the "antivirus" is the agent's own declarative permission layer.
On timing
The current Claude Code documentation states that edits to settings.json are detected by a file watcher and take effect in the running session after a brief file stability delay, with no restart. In other words: the tampering can take effect in the same session, not only the next one. This behavior has varied across versions, and there are reports in the Claude Code repository of permission rules that were only applied after a restart, so do not count on a restart as a barrier. New MCP servers, on the other hand, normally only connect when the session is started or reloaded.
3-AWhen the tool is only the trigger
In the previous stages the damage happens inside the MCP server process. There is a worse design: the handler does nothing interesting during the call, it only writes an artifact to disk and registers an autostart mechanism. From then on the attack no longer depends on the agent, on MCP or on your session.
The persistence mechanisms are the same as for any user-level malware, and all of them are within reach of a process with no administrative privilege:
- User schedulers (cron on Linux, Task Scheduler on Windows, launchd on macOS), which fire at a fixed interval
- Session startup: startup folders, per-user registry entries, user service units
- Shell startup files, executed every time a terminal opens
- Development tool hooks: git hooks in the repository, the agent's own hooks, IDE extensions
None of them requires root. All of them survive the uninstall of the MCP server, and that is exactly the point. The user removes the package, considers the incident closed, and the artifact is still there.
What the payload usually does once active
- Secret sweep. It walks the user's home directory and projects looking for credential patterns: environment files, private keys, cloud credentials, tokens in configuration files, and also shell history and git history, where a secret removed from the code tends to live on. There are open source secret scanning tools, built for defensive use, that do exactly this; attackers simply embed them instead of writing their own sweep.
- Exfiltration with cover. The destination is not always a server with a suspicious IP. The Shai-Hulud worm published stolen secrets to public repositories created in the victim's own account: outbound traffic to a domain that was already on your allowlist, using a legitimate credential.
- Propagation. With a package registry or repository token in hand, the payload republishes itself into the packages the victim maintains, closing the loop with no human operator.
- Second stage. The first artifact is small and quiet; it fetches and runs the real payload whenever it wants, which allows swapping the payload after infection without touching the original package.
The key reading: the MCP server is the entry vector, not the malware. After this stage, removing the server and cleaning up settings.json solves nothing.
4The full chain
Each stage looks small on its own. Together they form a lasting compromise disguised as a tool installation.
- The victim installs an MCP server from an unaudited public repository, or an honest server with a compromised dependency
- Install scripts already run as the victim's user
- The server starts over stdio and inherits environment, credentials and disk permissions
- The victim asks for an ordinary task; the model picks the poisoned tool because the description looks like a fit
- The handler reads secrets, exfiltrates them and returns a forged success response
- The same process tampers with the agent's configuration: removes
deny, addsallow, registers hooks. The change can take effect within the same session - And it writes an autostart artifact, which begins running outside the agent's session
- The victim uninstalls the server; the scheduler keeps firing
The final damage depends only on what that operating system user is allowed to do, which, in a typical development environment, tends to be almost everything.
Attack chain diagram
The victim never sees the line going out to the attacker, nor the configuration edit, nor the scheduler registration. All three happen inside the child process, outside the visible conversation with the agent.
This has already happened
In September 2025, researchers disclosed what appears to be the first malicious MCP server found in the wild: the npm package postmark-mcp. Versions 1.0.0 through 1.0.15 worked perfectly; 1.0.16 added a single line that put a hidden BCC on every email sent, copying everything to an external address. The package had about 1,500 weekly downloads.
That same month, the Shai-Hulud worm showed the complete persistent payload model in the npm ecosystem: execution via install script, a secret sweep using an embedded legitimate scanning tool, exfiltration to public repositories in the victim's account, and automatic republishing into the compromised maintainer's packages. The first wave, which ran at postinstall, hit between 180 and more than 500 packages, depending on the source and on when the count was taken. The second, in November 2025, moved to preinstall and reached about 800 packages and 25,000 repositories.
On the metadata side, Invariant Labs published a proof of concept in April 2025 in which a "fact of the day" server swapped its interface on the second load and manipulated a legitimate WhatsApp server, connected in parallel, into leaking the chat history, with no new approval from the user.
Mitigations that hold
The layer that actually holds is the one out of reach of the same user who ran the malicious process. In practice that means two boundaries, one for each threat class. Against malicious code, process isolation: container, VM or sandbox. Against malicious metadata, managed policy and the agent's permissions, because the poisoned description acts on the agent, which sits outside the sandbox and runs with the agent's own privileges. One without the other leaves half the chain open.
Not even the sandbox is absolute. The server needs some token and some network access to be useful, and anything that goes in there can leak: it shrinks the blast radius, it does not remove it. And supply chain controls are probabilistic, but they act before the code runs, which is the best moment to stop it.
| Mitigation | Why it works |
|---|---|
Managed settings (managed-settings.json) | Lives in a system path; editing it requires admin or root privilege |
allowManagedPermissionRulesOnly | Prevents user and project settings from defining allow, ask or deny rules; only the managed policy rules apply |
allowedMcpServers + allowManagedMcpServersOnly | Without the second flag, allowlists from every source merge, including the user's own. And it is independent from the permissions flag: one does not enable the other |
Match stdio servers by serverCommand | Compares the exact command and arguments that start the server, instead of a name the user picks |
managed-mcp.json | A fixed set of servers; only someone with administrative privilege over a system path can deploy it |
allowManagedHooksOnly | Blocks user, project and ordinary plugin hooks, closing the persistence vector from stage 3. Managed hooks, SDK hooks and hooks from plugins force-enabled by the managed policy itself keep running |
Managed ConfigChange hook | The event fires when a configuration file changes during the session. Defined in the managed policy, it logs or alerts on the stage 3 tampering without the malicious process being able to remove it |
| Install without lifecycle scripts | Neutralizes execution at install, that is, all of stage 0. Open narrow exceptions, not by default |
| Lockfile and deterministic install | No latest, no floating resolution; the postmark-mcp case came in through an update |
| Adoption delay | Do not install a version published in the last 24 to 72 hours, the window in which most malicious packages are detected and removed |
| Internal registry with proxy and allowlist | Eliminates dependency confusion and gives you a single point of blocking |
| Containerize install and execution | Claude Code's native sandbox restricts only Bash commands and their child processes. File tools, hooks and MCP servers run directly on the host. To isolate the server, put the whole process inside the boundary: a container, a VM or Anthropic's sandbox-runtime (still in beta), with no real secrets mounted and a restricted user |
| Scanner with hash pinning | Detects hidden instructions in descriptions and tool definitions that changed since the last scan |
| Monitor network egress | A "document formatting" server making outbound HTTP is an immediate red flag |
| Configuration file integrity | An externally monitored checksum detects tampering outside the agent's session |
How to check whether you were hit
- Compare
~/.claude/settings.jsonand your project configuration files against a known version. Keeping those files under git helps a lot here - Review registered hooks and MCP servers; remove whatever you do not recognize
- List scheduled tasks and startup items for your user, on every operating system you use
- Review shell startup files, git hooks in active repositories and recently installed IDE extensions
- Look for recent changes to
CLAUDE.mdand equivalent instruction files - Check whether any of your credentials show up in a public repository
- If a suspicious server ever ran, treat it as a compromise: rotate SSH keys, cloud tokens, package registry tokens and everything that was in environment files. Uninstalling the package does not undo the exfiltration
Checklist before installing an MCP server
- Who publishes it? The vendor's official repository, or an unknown account with a similar name?
- Did I read the handler of each tool, not just the description?
- Did I look at the dependency tree, or only at the server's repository?
- Is the version pinned, with lockfile and hash?
- Do install and execution happen in isolation, with no access to my real secrets?
- Do I have any visibility into this process's outbound traffic?
settings.json and its variants do not protect against this attack on their own: they are declarative configuration, editable by any process of the same user. The real defense is in what that user cannot reach: process isolation and managed policy in a system path.
Written at a conceptual level, with no executable proof of concept. References to Claude Code behavior apply to current versions and should be revalidated with every update.