MCP indirect prompt injection — coverage & roadmap
Audit of the MCP scanner against the OWASP indirect-prompt-injection testing guide, plus the design for the remaining gaps.
Architecture context
The scanner is a direct MCP client: lib/target-adapter.ts dispatches one
MCP operation per attack (discover / tools/call / resources/read /
prompts/get) and lib/response-analyzer.ts grades the returned JSON. There is
no model-in-the-loop deciding tool calls from retrieved content. This shapes
what is and isn’t testable: we can exercise the MCP server surface directly,
but not “does an agent, once manipulated, chain into an unauthorized write.”
Coverage status
| Guide requirement | Status | Notes |
|---|---|---|
| #9 Tool-metadata poisoning | ✅ Full-schema | lib/mcp/metadata-poisoning.ts scans every schema field (descriptions, parameter names/defaults/required arrays, non-standard keys, server instructions) with a research-backed signal set (hidden <IMPORTANT> tags, ~/.ssh/id_rsa exfil targets, concealment directives, imperative “before using this tool”). Grounded in Invariant Labs / CyberArk FSP / OWASP MCP03:2025. |
| MCP namespace collision / shadowing | ✅ New module | mcpToolShadowingModule + scanToolShadowing: cross-tool redirect directives, duplicate/near-duplicate names, tool-name squatting (Invariant shadowing PoC, OWASP MCP09:2025). |
| Insecure output handling / ATPA | ✅ New module | mcpInsecureOutputModule + scanToolResultInjection: inspects tool results for fabricated errors demanding secrets and injected instructions (CyberArk ATPA, OWASP LLM01). |
| Token audience / confused-deputy | ✅ New module | mcpAuthAudienceModule + lib/mcp/auth-probe.ts + auth_probe op: presents absent / invalid / wrong-audience (RFC 8707) credentials and grades acceptance as a vulnerability (MCP authorization spec MUST-reject / HTTP 401). Network transports only. Enable via the tool_permission_escalation category. |
| Rug-pull / sleeper mutation | ✅ New module | mcpRugPullModule + diffMcpMetadata + rug_pull_probe op: loads tools/list twice and diffs metadata; new poisoning on a later load → PASS, benign drift → PARTIAL (Invariant sleeper PoC, OWASP MCP03 rug-pull). Enable via mcp_server_compromise. |
| #4 Grade on execution trace | ✅ Partial | analyzeMcpTrace grades on the wire transcript; flags cross-tool chaining (a single-tool attack that invoked additional tools). |
| #1/#4 Canary detection | ✅ Hook ready | analyzeResponse detects a planted _canary reflected in server output/trace. Seeding canaries into a controlled environment still needs the agent-in-the-loop mode below. |
| #2b Tool-output injection | ⚠️ Detect-only | Detects injection strings served by the server; cannot confirm an orchestrator would act on them (no agent). |
| #2a/#2c/#2d Document / email / web-page channels | ❌ | No channel matrix; needs seeded content sources. |
| #2e Cross-tool read→write | ✅ Harness built | analyzeMcpTrace catches server-side chaining; model-driven chaining now covered by agent-loop.ts (write-after-poisoned-read). Pipeline wiring pending. |
| #3 Attack corpus × 8 channels | ⚠️ | Families exist; channel matrix not modeled. |
| #6 Argument provenance / taint | ✅ Harness built | taintedArgKeys in agent-loop.ts flags retrieval-sourced values reaching write arguments. Pipeline wiring pending. |
| #8 Approval-flow testing | ❌ | Needs agent mode with an approval gate. |
Agent-in-the-loop MCP mode — status
Core harness: implemented in lib/mcp/agent-loop.ts (tested in
tests/mcp-agent-loop.test.ts). It drives an LLM that holds the MCP tools via a
portable ReAct/JSON protocol (the provider layer is text-only), seeds poisoned
content into a designated read tool’s result, and grades the agent’s own
tool-call trace against the OWASP fail conditions:
- write-after-read — a write/consequential tool invoked after the poisoned
read (
classifyToolRisk+afterPoisonedRead); - canary exfiltration — the planted marker reaching an outbound argument;
- argument taint (#6) — a value lifted verbatim from retrieved content
flowing into a write argument (
taintedArgKeys).
The loop is dependency-injected (chat, executeTool) so it is unit-testable
without a live model or server; runMcpAgentLoop adapts a real provider +
McpSession.
Wired into the run pipeline. Enable per MCP target with
target.mcp.agentLoop: true. When set, round 1 builds agent-loop attacks from
the discovered tool surface (buildAgentLoopAttacks, one per read-tool carrier ×
injection template), dispatched through the new agent_loop MCP operation in
lib/target-adapter.ts. analyzeMcpResponse maps compromised → PASS/FAIL and
surfaces the behavioral findings; a genuine compromise is protected from the LLM
judge by the strong-evidence gate. The loop honors the engagement’s
allow/deny tool scope — a blocked call is still recorded and graded (the attempt
proves the injection landed) but the destructive tool is never executed.
Channel matrix + provenance implemented: six attack families framed across
ingestion channels (document, tool_result, email, ticket, calendar, web_page),
capped via maxAttacks to bound token cost; grading now flags destination-taint
(retrieved content choosing a recipient/URL/path) and suppressed disclosure (a
write the agent hides from the user). Remaining polish: memory/database channels
and taint beyond verbatim-substring matching (e.g. transformed/encoded values).
Proposal detail: the remaining gaps
The gaps that matter most — cross-tool chaining (#2e), write-triggered-by-content (#2b), argument provenance (#6), and true canary seeding (#1) — all require the same missing capability: a harness that drives an LLM which holds the MCP tools, seeds attacker-controlled content into what a read tool returns, and observes whether the model chains into a write.
Shape
Attack config: agent-mcp mode
├─ scanner spins up (or points at) an LLM agent bound to the MCP tools
├─ scanner controls a "read" tool's return value (seeded poisoned content
│ carrying an instruction + a unique canary)
├─ user-role prompt asks a benign task ("summarize the document")
└─ scanner observes the full tool-call trace the agent produces
New signals this unlocks
- Write-after-read (#2b, #2e) — fail if a
write-classed tool is invoked in the same turn whose trigger traces back to seeded content.analyzeMcpTracealready extracts the invoked-tool list; extend it with a read/write tool classification and a “caused-by seeded content” link. - Argument provenance / taint (#6) — tag every seeded value with its source
(
untrusted_retrieval) and fail when a tainted value flows into a consequential argument (recipient, URL, path). Enforce rules like “untrusted content may influence summary text but may not choose an email recipient.” This is taint tracking for tool arguments. - True canary (#1) — seed
RT-CANARY-*fake secrets into the agent’s environment/context; the existing_canarydetector inanalyzeResponsealready fails the test if the canary surfaces in an outbound argument. Only the seeding half is missing. - Channel matrix (#2a/#2c/#2d, #3) — parameterize the seeded content’s carrier (document / email body / web page / DB record / tool result / memory) and run each attack family through every channel.
Reuse from what already exists
McpExecutionTrace+analyzeMcpTrace— trace extraction is done._canarydetection — done.MCP_INJECTION_PATTERNS+ metadata scan — reused as the seeded-content corpus.- The 8 fail-conditions from the guide become deterministic checks over the agent’s tool-call trace.
Non-goals / honesty
Canary seeding and provenance enforcement are only meaningful with an agent + controlled environment. Against a black-box production MCP server the scanner stays a server-surface scanner; that limitation is inherent, not a bug.