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 seesWhat 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 deny entries and add allow ones, 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

  1. The victim installs an MCP server from an unaudited public repository, or an honest server with a compromised dependency
  2. Install scripts already run as the victim's user
  3. The server starts over stdio and inherits environment, credentials and disk permissions
  4. The victim asks for an ordinary task; the model picks the poisoned tool because the description looks like a fit
  5. The handler reads secrets, exfiltrates them and returns a forged success response
  6. The same process tampers with the agent's configuration: removes deny, adds allow, registers hooks. The change can take effect within the same session
  7. And it writes an autostart artifact, which begins running outside the agent's session
  8. 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

Victim (user)Agent (Claude Code)MCP server(child process, stdio)Persistent artifact(scheduler / startup)Attacker serverinstalls and starts the servercode already runs:inherits user, env vars, diskasks for an ordinary tasktools/call format_document(path)real handler:reads .env, ~/.ssh, credentialsPOST with sensitive dataforged result ("success")edits settings.json: removesdeny, adds allow and hookswrites artifact, registers autostartshows a normal responseuninstalls the serverkeeps firingoutside the sessionperiodic sweep and exfiltration

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.

MitigationWhy it works
Managed settings (managed-settings.json)Lives in a system path; editing it requires admin or root privilege
allowManagedPermissionRulesOnlyPrevents user and project settings from defining allow, ask or deny rules; only the managed policy rules apply
allowedMcpServers + allowManagedMcpServersOnlyWithout 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 serverCommandCompares the exact command and arguments that start the server, instead of a name the user picks
managed-mcp.jsonA fixed set of servers; only someone with administrative privilege over a system path can deploy it
allowManagedHooksOnlyBlocks 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 hookThe 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 scriptsNeutralizes execution at install, that is, all of stage 0. Open narrow exceptions, not by default
Lockfile and deterministic installNo latest, no floating resolution; the postmark-mcp case came in through an update
Adoption delayDo 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 allowlistEliminates dependency confusion and gives you a single point of blocking
Containerize install and executionClaude 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 pinningDetects hidden instructions in descriptions and tool definitions that changed since the last scan
Monitor network egressA "document formatting" server making outbound HTTP is an immediate red flag
Configuration file integrityAn externally monitored checksum detects tampering outside the agent's session

How to check whether you were hit

Checklist before installing an MCP server

  1. Who publishes it? The vendor's official repository, or an unknown account with a similar name?
  2. Did I read the handler of each tool, not just the description?
  3. Did I look at the dependency tree, or only at the server's repository?
  4. Is the version pinned, with lockfile and hash?
  5. Do install and execution happen in isolation, with no access to my real secrets?
  6. 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.

All articles