Defenses at the Agent, RAG, and MCP Layers
Which system controls absorb the injection and PII leakage that arrive through tool calls, RAG context, and MCP metadata.
Contents
An agent's input is not a single user query. Tool results, RAG context, and MCP (Model Context Protocol) tool descriptions all join the LLM prompt on every turn, so each join point needs its own validation gate.
Why the leakage surface grows in an agent
Beyond the user query, an agent injects tool results and RAG context into the LLM on every turn. All of these byproducts end up in the body sent to an external LLM, so masking only the user text at the entry point leaves new paths open. Every join point creates a potential leakage or injection vector.
- Member data contained in tool results
- PII embedded in the RAG index
- Tool descriptions advertised by an MCP server
Academic evaluations quantify the risk of that surface. On the InjecAgent benchmark, ReAct GPT-4 starts at a 24% attack success rate (ASR) and roughly doubles under the enhanced setting. In the MCPTox evaluation (arXiv:2508.14925), o1-mini reaches 72.8% ASR. Even Claude-3.7-Sonnet, the model with the highest refusal rate, refuses less than 3% of the time, meaning it responds to more than 97% of attacks. The stronger a model is at instruction following, the more faithfully it also follows injected instructions.
Of the three paths, MCP tool metadata shows the highest measured ASR, up to 72.8% for o1-mini in MCPTox. Tool calls and RAG context are the same kind of join point, so each needs system-level controls of its own.
Tool calling
Tool calls are where PII leaks most often. The LLM fills tool arguments and copies PII from the user text straight through, or an injection payload embedded in a tool result steers the LLM's next decision. Both directions need blocking.
Argument scrubber plus allowlist
Immediately before calling a tool, run the argument dict through the anonymizer once more.
def scrub_tool_args(tool_input: dict) -> dict:
return {k: anon.anonymize(v) if isinstance(v, str) else v
for k, v in tool_input.items()}In LangGraph, attach this scrubber as a pre-hook on ToolNode and register the permitted arguments per tool in a whitelist. Reject free-text arguments where possible and accept a structured dict or an enum instead. Free text creates bypass paths that schema enforcement alone cannot close.
Destructive actions (sending mail, payments, deletion, external transmission, exec) belong in a separate category that forces human approval through LangGraph's interrupt(). Running a model with ASR in the 70% range in production without human approval means a single bad model decision reaches the outside world directly.
Tool results are untrusted input
The text a tool returns is not a trusted source. If it is a response from an external API or a free-form input field in an internal database, it can carry an injection payload. Indirect Prompt Injection in the Wild (arXiv:2601.07072) reports an 80%+ success rate for SSH key exfiltration in a GPT-4o multi-agent workflow. The attack needed only a single poisoned email. The cost was 0.21 dollars per query, with zero user interaction.
The response has two stages.
- Role isolation — pass tool results to the LLM under the
toolrole rather than system, and state in the system prompt that no instruction inside a tool result may be treated as a user command. - Guardrail pass — because role isolation alone does not stop IPI (as the research confirms), run tool results through a guardrail model before feeding them into the next LLM call.
The RAG index
RAG handles the user query and the corpus at the same time, so PII enters through several points. Member data embedded in the index, user identifiers riding along in queries, and sensitive information mixed into answers all have to be handled. So does the possibility of the index itself being poisoned.
Removing PII at indexing time
Detect PII in the corpus before indexing and mask it, replace it with a hash, or swap in an anonymized reference ID. Start with LangChain's PebbloSafeLoader(anonymize_snippets=True) and, for Korean-language environments, add Presidio Korean NER plus a PatternRecognizer to the same pipeline. In some scenarios it is worth keeping the original in a separate secure store and indexing only a summarized or substituted version to shrink the exposed surface.
The thing most often skipped at this stage is the classification of the embeddings themselves. Embedding inversion research (Vec2Text reproductions) reports reconstructions close to plaintext depending on input length and embedding model. Since holding embeddings alone can get you back to near-plaintext, embeddings belong at confidential classification or higher. Make a self-hosted vector DB (Qdrant or pgvector) with 8-bit quantization the default, and apply noise of sigma 0.005 to 0.01 as an option.
Permission filters and trust scores at retrieval time
Two more steps come before search results enter the LLM context. First, filter results through role-based access control (RBAC). Documents the user cannot see must never appear in the results. Second, run sanitization once more just before context injection to catch anything masking missed.
PoisonedRAG (arXiv:2402.07867) reports roughly 90% ASR from inserting only five poisoned documents per target question. It works even against indexes with a million entries. The response is to make the operational trust model explicit.
- Assign a trust score per content source (official docs > wiki > external blogs)
- Cross-check across multiple sources in the results, with k of 5 or more, and no answers based on a single document
- Approval workflow for indexing so that automated crawl output passes review before it reaches the index
MCP
MCP (Model Context Protocol) is the standard for exposing external tools to an LLM, but its security model varies widely by client. A threat model analysis of MCP (arXiv:2603.22489) sorts payload injection sites into four categories: tool name, description, parameter schema, and output spec. The LLM reads and follows this metadata even when the tool is never executed.
A follow-up evaluation comparing seven major MCP clients (arXiv:2603.21642) reports that Claude Desktop has strong guardrails. Cursor, by contrast, is broadly vulnerable to cross-tool poisoning, hidden parameter exploitation, and unauthorized tool invocation. On top of an identical standard, the security level splits sharply by client.
Using LangChain as an MCP client, the response looks like this.
- Pass tool descriptions through a content policy filter before exposing them to the LLM, blocking imperative constructions
- Enforce parameter visibility so that every parameter is shown to the user and hidden parameters are disallowed
- Track decision paths with an audit log of which tool description influenced which decision
- Sandbox untrusted MCP servers when registering them
The implication follows: which client or host you use can be a bigger security decision than which model you use.
State design
In a LangGraph deployment, the shape of the State determines the leakage surface. Serializing a secret such as a reversible mapping into the checkpoint turns the database, from that moment on, into a shadow database pairing plaintext PII with its fake values.
class PublicState(TypedDict):
safe_query: str
safe_answer: str
class PrivateState(TypedDict):
pii_map: dict # session-scoped, never persistedsafe_query and safe_answer in PublicState are masked values, so serializing them is safe, while PrivateState is memory-only and never touches disk. Keep pii_map and deanonymizer_mapping in PrivateState so they live only in memory, and let the checkpointer serialize PublicState alone.
Encrypt the checkpointer itself with PostgresSaver plus EncryptedSerializer.from_pycryptodome_aes(KMS_KEY). Set the LANGGRAPH_STRICT_MSGPACK=true environment variable to block serialization bypasses. If you use the SQLite checkpointer, never put external input in a metadata filter key.
Mapping the layered defenses
The defenses at this layer map out as follows.
| Defense | Effective against | Limitation |
|---|---|---|
| Static tool description validation | Metadata poisoning | Natural-language bypass |
| Enforced parameter visibility | Hidden parameter attacks | Degrades UX |
| Multi-agent inspection pipeline | Direct injection | About 3x the cost |
| Sandboxed execution | RCE | Restricts tool capability |
| Human approval (interrupt) | Destructive actions | Loss of automation |
| Role isolation (system/user/tool) | IPI | Many documented bypasses |
| Tool allowlist plus argument schema | Arbitrary invocation | Useless against abuse of legitimate tools |
| Provenance / trust score | RAG corruption | Requires supply chain trust |
| Output validator agent | Exfiltration | Latency |
| Intent verification | Task drift | Extra call cost |
What matters is that every defense has a documented case where it fails on its own. A tool allowlist alone is useless against an attack that uses a permitted tool for an unintended purpose, and role isolation alone is broken by the IPI in the Wild cases.
Deploying five or six of them together, so that the next layer holds when one collapses, is the safe default.
Summary
At the agent layer the leakage surface splits three ways: tool calls, RAG, and MCP. Tools are handled with an argument scrubber and an allowlist, and destructive actions require human approval.
RAG is absorbed by removing PII at indexing time and applying permission filters and trust scores at retrieval time. MCP gets layered defense through a content policy filter on descriptions and enforced parameter visibility. LangGraph State separates Public from Private to keep reversible mappings out of the checkpoint.
The next post covers the egress gateway that external LLM traffic passes through last, including LiteLLM, Cloudflare AI Gateway, and Portkey.