Skip to content
Guide intermediate

MCP Tool Poisoning: How to Audit the Servers Your Agents Call

The short version

A defender's checklist for MCP tool poisoning. The attack path, the five controls Microsoft, OWASP, and TrueFoundry agree on, per-tool notes for Cursor, n8n, and Firecrawl, and a copyable audit table you paste into your runbook.

Published July 4, 2026 by Pondero Research
Table of Contents

MCP Tool Poisoning: How to Audit the Servers Your Agents Call

Published July 4, 2026 by Pondero Research

A poisoned MCP tool description hit a 72.8% attack success rate against 45 real servers and 20 leading models in the MCPTox benchmark, and the models almost never refused (per the MCPTox paper, arXiv, August 2025). That number is the whole story. If your agent connects to a third-party MCP server, an attacker who edits that server's tool description (plain text, not code) can steer the agent to exfiltrate your data using your own credentials, and in a default setup no alert fires. On June 30, 2026, Microsoft Incident Response and its Defender research team published the mechanism in detail (Microsoft Security Blog). This guide is the part the advisory skipped: what you actually change on Monday.

Here is the one thing to take away. There are five controls, they compound, and you do not need all five to matter. An approved-server list plus description pinning closes most of the supply-chain path with zero runtime overhead. The other three raise the ceiling. Below is the attack path, then the five controls ranked by payoff, then where Cursor, n8n, and Firecrawl sit in the trust model, and a copyable audit table at the end.

The trust boundary you did not know you had

MCP tool poisoning is an indirect prompt injection that rides in metadata instead of user input. Every MCP tool ships with a description, a few lines of plain text that tell the agent what the tool does and when to call it (per The Hacker News, June 30, 2026). The agent reads that text to decide how to act. That is the weakness in one sentence: the description is words, and words can carry instructions.

When an agent connects to a server, the client issues a tools/list JSON-RPC call and gets back an array of descriptors. Name, natural-language description, JSON-Schema input shape. The client merges those descriptors with every other connected server's and serializes the lot into the model's context, usually alongside your own system prompt (per TrueFoundry, May 5, 2026). The model sees one undifferentiated context. It has no reliable way to tell an instruction written by your developer from one slipped in by whoever maintains a vendor's server. Both are just bytes in the window, and the window is the only authority the model recognizes.

OWASP names the root cause precisely: a trust gap between connect-time and runtime. Tool descriptions get reviewed once, when the agent first connects. Tool responses go straight into the context with no equivalent check (per OWASP). That unguarded runtime channel is what the attacker abuses.

Microsoft's worked example makes the shape concrete. A finance team stands up a Copilot Studio agent to handle vendor invoices. It connects to three tools, one of them a third-party invoice-enrichment MCP server that was approved for use but never given a real security review. An attacker updates that server. The visible name and summary stay the same. Buried in the description, dressed as formatting notes, is a hidden order: grab the last thirty unpaid invoices and attach them to the next call (per Microsoft, June 30, 2026). MCP picks up description changes on the fly, so without a re-approval trigger the poisoned version goes live with no extra review. An analyst asks a routine question about a supplier. The agent follows the hidden order, collects the invoices, sends them as part of a normal-looking request, and the tool quietly copies the data to a server the attacker controls. Each move is legitimate on its own. The query ran with the analyst's own permissions.

This is not theoretical. Invariant Labs named tool poisoning in April 2025 with a proof of concept that hid instructions in a calculator tool's description and got the Cursor editor to read a user's private SSH key and send it off (per The Hacker News). Two CVEs, MCPoison (CVE-2025-54136) and CurXecute (CVE-2025-54135), put the category on the map (per TrueFoundry). And the supply-chain version already shipped in the wild.

The postmark-mcp case

In September 2025, Koi Security found an npm package called postmark-mcp that mirrored a legitimate email tool for fifteen clean releases. Version 1.0.16 slipped in one line that silently BCC'd every email the agent sent to an attacker. Koi called it the first real-world malicious MCP server (per The Hacker News). Fifteen clean versions is the part that should change how you think about this. You cannot vet an MCP server once and be done. The rug pull happens on the update you did not read.

The five controls, ranked by payoff

Microsoft, OWASP, and TrueFoundry converge on the same defenses. What follows orders them by payoff, so you can stop after two and still cover most of the supply-chain path. Each control is a concrete change, not a principle.

1. Keep an explicit approved-server list per agent

Maintain a text file in your repo listing every approved MCP server by package name and pinned version. Your agent config or a CI step refuses to load any server not on it. This is the control with the highest payoff because it kills shadow servers and most supply-chain swaps with zero runtime cost. Microsoft's version is a tenant-level allowlist of approved publishers plus turning off "allow all" so an agent uses only the specific tools it needs (per Microsoft). OWASP frames the same rule as: do not let users connect to arbitrary servers (per OWASP).

A minimal allowlist looks like this. Keep it in version control so every change is reviewed:

# mcp-allowlist.yml. Reviewed on every change, enforced in CI.
approved_servers:
  - name: firecrawl-mcp
    version: "2.4.1"
    publisher: firecrawl
    blast_radius: read-web
  - name: n8n-internal
    version: self-hosted
    publisher: internal
    blast_radius: workflow-exec
# Any server not listed here is refused at load time.

A CI gate that fails the build when a config references a server outside the allowlist:

#!/usr/bin/env bash
# check-mcp-allowlist.sh. Run in CI; exit 1 on any unlisted server.
set -euo pipefail
allowed=$(yq '.approved_servers[].name' mcp-allowlist.yml)
configured=$(yq '.mcpServers | keys | .[]' .mcp/config.yml)
for s in $configured; do
  if ! grep -qx "$s" <<< "$allowed"; then
    echo "BLOCKED: MCP server '$s' is not on the approved list" >&2
    exit 1
  fi
done
echo "OK: all configured MCP servers are approved"

2. Pin and hash tool descriptions on first approval

When you approve a server, capture a hash of each tool's full description and input schema. On every connect, re-hash and compare. If any description changed, halt and re-prompt for human review before the agent runs. This closes the postmark-mcp path directly. Fifteen clean versions do not help an attacker if version sixteen fails the hash check. Microsoft's framing is to treat a tool description like a system prompt and review changes to it with the same rigor as a code change (per Microsoft). Practical DevSecOps states it plainly: hash and pin descriptions at first approval (per Practical DevSecOps, May 26, 2026).

A small script that captures the baseline and flags drift:

# pin_descriptions.py. Capture on approve, verify on connect.
import hashlib, json, sys

def tool_hash(tool: dict) -> str:
    payload = json.dumps(
        {"name": tool["name"],
         "description": tool["description"],
         "inputSchema": tool.get("inputSchema", {})},
        sort_keys=True,
    ).encode()
    return hashlib.sha256(payload).hexdigest()

def verify(tools: list, pinned: dict) -> int:
    drift = 0
    for t in tools:
        current = tool_hash(t)
        if pinned.get(t["name"]) != current:
            print(f"DRIFT: {t['name']} description changed since approval", file=sys.stderr)
            drift += 1
    return drift

# On approval: pinned = {t["name"]: tool_hash(t) for t in tools}; json.dump(pinned, open("pins.json","w"))
# On connect:  sys.exit(1 if verify(current_tools, json.load(open("pins.json"))) else 0)

Run verify before the agent loop starts. A non-zero exit blocks the run and pages you.

3. Containerize local server runtimes and drop host mounts

Run each local MCP server in its own container with no host filesystem mounts it does not need, no ambient credentials, and a network egress allowlist. The point is blast-radius containment. If a poisoned description talks the agent into reading a file, the file it can reach is inside a sandbox, not your home directory. This is where the Invariant Labs SSH-key demo would have failed. OWASP's version: isolate high-privilege tools (file access, database, internal APIs) in a separate context that external servers cannot reach, and apply least privilege (per OWASP). Practical DevSecOps adds process-boundary isolation per session with no shared filesystem (per Practical DevSecOps).

A container that gives a local server exactly one read-only working directory and nothing else:

# Run a local MCP server sandboxed: no host home, one scoped mount, capped resources
docker run --rm -i \
  --network none \
  --read-only \
  --tmpfs /tmp:size=64m \
  --mount type=bind,source="$PWD/agent-workspace",target=/work,readonly \
  --cap-drop ALL \
  --memory 512m --pids-limit 128 \
  mcp/local-server:2.4.1

Swap --network none for a narrow egress allowlist only if the server genuinely needs to reach one known host.

4. Put an MCP gateway in front of remote servers

Route every remote MCP server through a gateway that validates tool schemas before they reach the model, applies rate limits and request-size caps, and writes an audit log to your SIEM. Client-side fixes cannot close this, and the reason is structural: a typical org consumes MCP through a dozen clients on different release cadences, many of which cache discovery results and never invalidate a poisoned schema (per TrueFoundry). A gateway moves the boundary off the laptop and onto the network, where one team owns the policy for the whole org.

TrueFoundry's gateway runs a five-stage pipeline: server-trust check against a registry, RBAC on the calling principal, shape validation (Unicode normalized, zero-width characters stripped, unexpected keys dropped), sanitization with an escalation to a small LLM judge for ambiguous schemas, and a runtime re-check of every parameter on invocation (per TrueFoundry). The design decision worth stealing even if you build your own: default-deny, not blocklist. Allowlisting drops everything not explicitly permitted, so the recurring cost is zero while the offense keeps inventing new payloads. Practical DevSecOps makes the same call: put a gateway in front, apply rate limits and audit logging from one place (per Practical DevSecOps).

5. Treat every tool return as data, never as commands

The last control catches what the first four miss: a clean tool called with clean arguments can still return a response that carries injected instructions. Constrain output format. Where you can, require structured JSON against a fixed schema and reject anything that does not match. Strip instruction-like patterns (SYSTEM, IGNORE, OVERRIDE, <IMPORTANT>) from returns before they re-enter the model, and require explicit user confirmation for anything destructive or data-exfiltrating (per OWASP). Fully detecting injected instructions in free text is an open problem, so schema validation is the reliable part; the pattern scrub catches the obvious cases. A post-execution hook that redacts secrets and PII from tool output before the agent sees it is the same idea one layer down (per TrueFoundry).

# guard_tool_output.py. Reject free-text returns; scrub instruction patterns.
import re, jsonschema

BANNED = re.compile(r"(SYSTEM\s*(NOTE|OVERRIDE)|IGNORE\s+(ALL|PREVIOUS)|<IMPORTANT>|\[.*DIRECTIVE.*\])", re.I)

def guard(tool_output: dict, schema: dict) -> dict:
    jsonschema.validate(tool_output, schema)          # reject off-schema returns
    blob = str(tool_output)
    if BANNED.search(blob):
        raise ValueError("Tool return contains instruction-like patterns; quarantined")
    return tool_output

Where Cursor, n8n, and Firecrawl sit in the trust model

The controls above are generic. Here is how they land on three tools Pondero readers actually run.

Cursor is the client that made this attack famous. The original Invariant Labs proof of concept used a poisoned calculator description to walk Cursor into reading a private SSH key (per The Hacker News). If your team runs Cursor with MCP servers, controls 1 and 2 are the ones that matter most, because Cursor connects developer machines directly to third-party servers. Cursor's Enterprise tier adds repository, model, and MCP access controls at the team level (Cursor pricing), which is where you enforce an org-wide approved-server list instead of trusting each developer's local config. Cursor has no affiliate program with Pondero; we name it because it is the tool most readers are exposed through.

n8n removes the third-party trust problem for automation, and that is a real argument, not a pitch. n8n ships a native MCP Server Trigger node (n8n docs), so if you self-host n8n the MCP server is your own instance. You control the tool descriptions, and there is no external maintainer who can push a poisoned version 1.0.16. Control 2 still applies (pin your own descriptions so an insider change is caught), but the supply-chain vector in the postmark-mcp case simply does not exist when you are the publisher.

Firecrawl is the shape you want in a third-party server you do approve. Its MCP interface ships from a named publisher with a public changelog and release notes, and a free tier of 1,000 credits per month to evaluate before you commit (Firecrawl pricing). Contrast that with an anonymous PyPI-distributed server whose author you cannot name and whose updates you cannot diff. When Firecrawl ships a new version, control 2 gives you a hash to compare against; a named publisher with a changelog is the baseline that makes the diff meaningful. Provenance is not a feature you can bolt on later. It is the thing you check at approval time.

The audit table

Paste this into Notion or your runbook and fill one row per MCP server per agent. The first two rows are examples. The five final-pass questions underneath come straight from Practical DevSecOps (per Practical DevSecOps); if you cannot answer them for a server, it is not approved yet.

ServerPublisherVersion pinnedDescription hash capturedLast reviewedBlast radiusApproved
firecrawl-mcpFirecrawl (named)2.4.12026-07-042026-07-04read-web onlyYes
n8n-internalInternal (self-hosted)self-hosted2026-07-042026-07-04workflow-execYes
invoice-enrichanon PyPIunpinnednoneneverread finance dataNo

Before any server gets a "Yes," answer these five:

  1. Who can call this server, and how is that decision enforced?
  2. What is the worst thing a malicious tool description can make the agent do?
  3. If this server is compromised, what is the blast radius?
  4. How will I detect a compromise within 24 hours?
  5. How will I roll it back?

What to do first

If you run agents against external MCP servers today, do two things this week. Write the approved-server allowlist (control 1) and add the description-hash check (control 2). Together they cost almost nothing at runtime and close the supply-chain path that got postmark-mcp into production. Containerize local servers next, stand up a gateway when you have more than a handful of remote servers across a team, and add output guarding last. Then work the audit table one server at a time, assign each row an owner, and set a review date. The teams that treat every MCP server as supply-chain infrastructure, and every tool description as a system prompt, are the ones whose agents survive the next poisoned update.