jongkwan.dev
Development · Essay №083

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.

Jongkwan Lee2026년 5월 3일8 min read
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.

python
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.

  1. Role isolation — pass tool results to the LLM under the tool role rather than system, and state in the system prompt that no instruction inside a tool result may be treated as a user command.
  2. 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.

python
class PublicState(TypedDict):
    safe_query: str
    safe_answer: str
 
class PrivateState(TypedDict):
    pii_map: dict  # session-scoped, never persisted

safe_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.

DefenseEffective againstLimitation
Static tool description validationMetadata poisoningNatural-language bypass
Enforced parameter visibilityHidden parameter attacksDegrades UX
Multi-agent inspection pipelineDirect injectionAbout 3x the cost
Sandboxed executionRCERestricts tool capability
Human approval (interrupt)Destructive actionsLoss of automation
Role isolation (system/user/tool)IPIMany documented bypasses
Tool allowlist plus argument schemaArbitrary invocationUseless against abuse of legitimate tools
Provenance / trust scoreRAG corruptionRequires supply chain trust
Output validator agentExfiltrationLatency
Intent verificationTask driftExtra 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.