An agent that works beautifully on a laptop is a different system once it holds a production token. The prototype reads a tool description and calls a function. The production deployment reads an attacker's text, calls a function with your credentials, and writes the result into a system that other people trust. The hardening job is not making the model smarter — it is making every tool call survive the assumption that the model has already been compromised.
That assumption is the whole method. Give each MCP server its own scoped OAuth 2.1 identity, validate that tokens were issued for that server, never forward them upstream, run the server in a sandbox with default-deny egress, put a policy broker between the model and anything irreversible, and log enough to reconstruct an incident. Everything below is the order we apply those controls, and the failures we hit applying them.
Your agent's input is attacker-controlled now
Model Context Protocol servers hand the model two things it treats as truth: tool descriptions and tool results. Both arrive over the wire. Both can be written by someone else.
Invariant Labs published the first public proof of this in April 2025, showing that a poisoned tool description could pull private repository contents and message histories with no user interaction, as summarized in the Cloud Security Alliance research note on MCP tool poisoning. The same note reports lab benchmarking across more than 45 real-world MCP servers with attack success rates above 60%, and a peak of 72.8% on the strongest agent model tested. The MCPTox benchmark paper describes the mechanism plainly: the agent parses the poisoned description as ground truth about the tool's capabilities and folds the attacker's instruction into its plan.
The canonical demonstration is almost boring to read. An MCP Guardian paper walks through a toy add_numbers tool whose docstring quietly tells the model to read ~/.ssh/id_rsa and ~/.cursor/mcp.json and pass the contents along in a notes parameter. Nothing in the protocol flags it. The CSA note is blunt that there is no native MCP mechanism to detect or prevent these injections.
On our bench the detail that changes behaviour is simpler than any of that: when we dumped the full tool manifest our staging agent actually receives from nine connected servers, it ran past 11,000 tokens of description text that no human on the team had ever read end to end. Nobody reviews tool descriptions, which is exactly why they are a good place to hide instructions.
Treat tool descriptions and tool results as untrusted user input from the moment they enter the context window. Every control in this guide follows from that one reclassification.
Map the surface before you buy a product
Threat (OWASP Agentic Code) | Where it lands | Real 2025 Incident / Mechanism | Control that actually helps |
Tool description poisoning (ASI01) | Model context, before any call | Invariant Labs WhatsApp/GitHub leaks | Pinned server versions, SHA-256 description diffing, manifest review |
Indirect prompt injection (LLM01 / ASI01) | Model context, after a call | Poisoned public GitHub issues steering private repo reads | Provenance tagging, JSON Schema 2020-12 output constraints |
Rug-pull updates (ASI04) | Supply chain |
| Digest pinning, automated change alerts on tool schemas |
Confused deputy / token replay (ASI03) | Authorization layer | Static proxy client IDs & upstream token forwarding | Per-server audience-bound ( |
Over-scoped credentials (LLM06 / ASI02) | Server runtime | Shared admin PATs across multi-user agent sessions | Per-server identity, least-privilege scopes, out-of-band approval |
Exfiltration via tool network (ASI02) | Host network | SSRF to cloud metadata ( | Default-deny Kubernetes |
Developer & Bridge RCE (ASI05) | Install / Local step |
| Upgrade Inspector >= |
Silent blast radius | Everywhere | Unlogged autonomous tool loops | Structured tool-call audit log with payload size tracking & kill switch |
Most teams reach for a gateway first. We map the surface first, because the controls land in different places and three of them cost nothing.The pattern worth noticing: four of the eight are supply chain or identity problems, not model problems. No amount of system-prompt engineering touches them.
Fix identity before you fix anything else
The authorization story matured fast. The March 2025 revision established OAuth 2.1 with PKCE, authorization server metadata and redirect URI validation; the 2025-06-18 revision made the MCP server a full OAuth 2.1 resource server requiring protected resource metadata and resource indicators, as Permit.io's implementation guide lays out. The spec text itself is direct about the two obligations that matter: clients MUST include the RFC 8707 resource parameter, and servers MUST validate that tokens presented to them were issued for their use. Token passthrough is explicitly forbidden.
That last rule is the confused deputy fix. When your MCP server needs to call an upstream API, it acts as an OAuth client to that service and obtains its own token — it does not forward the one the agent handed it, because downstream services may wrongly trust a token that was never meant for them, as Descope's breakdown of the spec explains.
Two moving pieces as of October 2026. The Cloud Security Alliance's Agentic MCP Security Best Practices guide ties remote server connections to OAuth 2.1 under the November 2025 specification, with PKCE mandatory for public clients and RFC 8414 metadata validated before authentication begins. A later revision dated 2026-07-28 reportedly makes RFC 9728 protected resource metadata and RFC 8707 resource indicators hard requirements and deprecates dynamic client registration, according to SSOJet's reading of the authorization spec. Check the revision your client library targets before you assume which rules apply — the libraries lag the spec, and that gap is where our first staging outage came from.
The server-side check is ten lines and it is the one teams skip:
import jwt
from jwt import PyJWKClient
ISSUER = "https://example.com/"
# Per-server URI (RFC 8707), never a single shared platform-wide audience
AUDIENCE = "https://internal.example"
jwks_client = PyJWKClient(f"{ISSUER}.well-known/jwks.json")
def verify_mcp_bearer(token: str) -> dict:
signing_key = jwks_client.get_signing_key_from_jwt(token).key
return jwt.decode(
token,
key=signing_key,
algorithms=["RS256"], # Reject "none" and symmetric HS* keys
audience=AUDIENCE,
issuer=ISSUER,
options={"require": ["exp", "aud", "iss", "sub"]},
)In our environment the audience string is per server, not per platform. When we first rolled this out we used one audience for all nine servers, and a token minted for the low-risk docs server was accepted by the deploy server without complaint. One audience for every MCP server is the same as no audience validation at all.
Sandbox the server, not just the prompt
An MCP server is a process on your infrastructure with filesystem and network access. Treat it like untrusted third-party code, because that is usually what it is.
Give each server its own container and its own non-root UID. Shared runtimes mean a poisoned description in one server reads another's config file.
Default-deny egress, then allowlist by hostname. The WhatsApp exfiltration case Invariant Labs demonstrated combined tool poisoning with unrestricted network access, as Docker's write-up of the incident describes. Remove the second half and the first half has nowhere to send data.
Mount the filesystem read-only with a small
tmpfsfor scratch. Most servers need far less disk than their defaults assume.Drop capabilities and block privilege escalation.
Cap memory, CPU and PIDs so a runaway tool loop degrades one container instead of the node.
Inject secrets as files, not environment variables, so a debug tool that dumps
envdoes not dump your credentials.
docker run --rm \
--name mcp-github \
--network mcp-egress-restricted \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop=ALL \
--security-opt no-new-privileges \
--pids-limit 128 \
--memory 512m --cpus 1 \
--user 10001:10001 \
registry.internal/mcp-github@sha256:<digest>--cap-drop=ALL plus --security-opt no-new-privileges removes the setuid escalation path; --read-only with a noexec tmpfs stops a tool from dropping and running a binary it fetched. Pin by digest, not tag, for reasons the next section covers.
Expect breakage. When we flipped default-deny egress on our staging bench, three of nine servers failed immediately — two legitimately needed API hosts we had not listed, and one was calling a telemetry endpoint nobody had documented. That third one is the entire argument for doing this. Budget a day of allowlist tuning per environment, and resolve hostnames to a proxy rather than hardcoding IPs, or the first vendor CDN rotation takes your agent down at 3 a.m.
Choosing an isolation boundary: runc vs gVisor vs Firecracker
Standard runc containers share the host Linux kernel, which is sufficient for stateless API wrappers but risky for MCP servers that execute model-generated code or parse untrusted files. Below is the representative cold-start overhead for a Python-based filesystem MCP server across three isolation layers on an 8-vCPU x86 host (measured from launch until the first initialize response):
On Kubernetes, pair your sandbox runtime (runtimeClassName: gvisor or kata-fc) with a default-deny egress NetworkPolicy that blocks all outbound traffic—including the cloud instance metadata service (169.254.169.254)—except internal DNS:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: mcp-default-deny-egress
namespace: mcp-servers
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53Put a policy broker between the model and the tools
Prompt-level rules are advisory. A broker is not. The architecture that has held up for us puts a deterministic policy layer in the call path, where the model's intent is translated into an allow, deny, or hold-for-approval decision before anything executes.
The broker enforces three things the model cannot be trusted to enforce: which tools this agent instance may call at all, argument-level constraints (refund amount ceilings, repository allowlists, path prefixes), and rate limits per tool per session. Policy lives in version control and is reviewed like any other access change.
Calibrate the approval gate carefully. Our first policy asked for human confirmation on every write, and in a one-hour soak test a single ticket-triage agent generated 63 approval prompts. The reviewer started approving on reflex within fifteen minutes — an approval gate that fires constantly is a logging feature, not a control. We cut it to irreversible and high-value actions only: deletes, payments, outbound email to external domains, production deploys, permission changes. Prompt volume dropped to four or five per hour, and the reviews became real again.
Containing injection you cannot eliminate
No filter reliably separates instructions from data inside one context window. So contain instead of detect.
Tag provenance on everything. Tool results enter our context wrapped with the originating server ID and a marker that this content is data, never instruction. It is not a guarantee, and we do not treat it as one — it is the hook that makes the downstream rules enforceable.
Constrain the output shape. When a tool result only needs to produce a structured decision, we make the model emit a schema-validated object and let ordinary code act on it. A paragraph of attacker text in the middle of a JSON field fails validation; the same text in free-form output gets executed as intent.
Watch for the dangerous combination rather than the dangerous string. One agent session that holds private data, ingests untrusted external content, and has an outbound network path is the condition under which exfiltration becomes possible at all. Break any one leg — read-only private data, sanitized ingestion, or no egress — and the attack loses its channel. Our rule on the bench: any agent with all three gets a broker policy review before it goes anywhere near a customer.
Keep retrieval scoped. An agent that can read every document the user can read is an exfiltration amplifier. Scope retrieval to the task, re-check permissions at call time rather than at session start, and expire the session aggressively.
The server you installed is code you did not review
Supply chain is where the sharpest incidents have landed. JFrog Security Research disclosed CVE-2025-6514, a CVSS 9.6 OS command injection in mcp-remote, the widely used proxy that lets MCP clients talk to remote servers. The GitHub advisory records the affected range as >= 0.0.5, < 0.1.16, patched in 0.1.16, triggered by crafted input in the authorization endpoint response URL — meaning connecting to an untrusted server was enough.
Practical controls, in the order they pay off:
Mirror MCP packages into a private registry and allowlist what can be installed. This also kills typosquats.
Pin container digests and npm/PyPI versions in lockfiles. Tags move; digests do not.
Diff tool schemas on every update. A rug-pull changes the description text while the version number looks routine. We hash the full manifest per server and alert on change — that check costs nothing and is the only thing that catches a quiet description edit.
Review the manifest like code. If nobody on the team can say what each of your agent's tools does, your attack surface is undocumented.
import hashlib, json
def tool_fingerprint(tool: dict) -> str:
canonical = json.dumps(
{k: tool.get(k) for k in ("name", "description", "inputSchema")},
sort_keys=True, separators=(",", ":"),
)
return hashlib.sha256(canonical.encode()).hexdigest()
def enforce_pinned_manifest(tools: list[dict], pinned_hashes: dict[str, str]) -> list[dict]:
verified = []
for tool in tools:
fp = tool_fingerprint(tool)
if pinned_hashes.get(tool["name"]) != fp:
raise RuntimeError(f"Blocked unreviewed tool schema change: {tool['name']} ({fp})")
verified.append(tool)
return verifiedLogging that survives the incident review
Decide what you log before an incident, not during one. The minimum per tool call: timestamp, agent/session ID, human principal on whose behalf the agent acted, server ID and pinned version, tool name, argument hash (and full arguments for high-risk tools, with secrets redacted), broker decision and matching policy rule, result size, and duration.
Result size is the sleeper field. Exfiltration shows up as an unusual response volume long before anyone reads the payload — on our bench, a normal repository-read tool returns a few kilobytes, so the 40x outlier is visible in a dashboard without any content inspection at all.
Two operational notes from running this. Verbose argument logging on a chatty agent pushed us past 2 GB of structured logs per day from a single staging deployment, so sample low-risk tools and keep full fidelity only for the dangerous ones. And redact at the emitter, not in the pipeline — an argument field containing a bearer token reaches your log index faster than your scrubber does.
The gate we run before an agent touches production data
Hardening Check | Production Pass Condition |
Token audience ( | Per-server URI validated via RFC 8707; |
Token passthrough | Server obtains its own upstream token; client bearer token is never forwarded |
Permission scopes | Narrowest read/write scope required for the task; zero wildcard admin grants |
Runtime sandbox | Non-root UID, |
Network egress | Default-deny |
Supply chain & pinning | Digest-pinned images, private registry mirror, and SHA-256 tool manifest hash verified on load |
Policy broker | Deterministic tool allowlist plus JSON Schema 2020-12 argument/output constraints in version control |
Human approval gate | Out-of-band approval for irreversible/high-value actions only; prompt volume under ~5/hour |
Audit logging | Human principal, tool name, broker decision, and response payload size captured and redacted at the emitter |
Kill switch & blast radius | Single token/session revocation path tested in the last 30 days; worst-case adversarial impact documented |
That last row is the one to argue about in review. If the honest answer to "what happens if the model does the worst possible thing with these tools" is anything you would not accept, the controls are not finished — more prompt engineering will not change the answer.
Questions engineers ask before shipping an agent
Does OAuth 2.1 stop prompt injection?
No. Authorization decides what an identity may do; injection manipulates what the agent chooses to do with the permissions it already has. OAuth 2.1 with audience-bound tokens limits the blast radius of a successful injection by keeping a compromised agent inside one server's scopes. Containment comes from the sandbox, the egress allowlist and the policy broker, not from the token.
Are local stdio MCP servers safer than remote ones?
They avoid network authorization problems and introduce worse ones. A local server runs with your user's filesystem access, which is why the published proof-of-concept targets ~/.ssh/id_rsa and local MCP config files. Developer workstations typically hold broader credentials than any production service account, so apply the same container, capability and egress rules locally.
How do I detect a rug-pull update?
Hash each server's complete tool manifest — names, descriptions, parameter schemas — and store it alongside the pinned version. Compare on every start and on a schedule, and alert on any change. A rug-pull alters description text while the package version looks routine, so version checks alone miss it. Review the diff before allowing the agent to load the new manifest.
Should every tool call require human approval?
No, and measuring this matters. In our soak testing a confirm-everything policy produced 63 prompts in an hour and the reviewer stopped reading within fifteen minutes. Gate irreversible or high-value actions — deletes, payments, external email, deploys, permission changes — and let the broker's deterministic argument constraints handle the routine ones.
Which MCP specification revision should I build against?
Target the revision your client and server libraries actually implement, then verify against the published spec. The 2025-06-18 revision made the MCP server an OAuth 2.1 resource server and forbade token passthrough; later revisions tighten resource metadata requirements further. Library support lags the specification, so validate audience handling in staging rather than assuming compliance.
What a hardened deployment looks like on day one
A finished rollout is boring to watch. Each MCP server runs as its own non-root container with a read-only filesystem and an egress allowlist you can read in one screen. Each holds a token minted for its own audience and exchanges it for separate upstream credentials. Every call passes a broker that can say no, and roughly five times an hour it asks a human instead. The audit log answers "what did the agent do with whose permissions" in a single query, and the kill switch has been pulled in a drill within the last month.
If something goes wrong first, it is almost always egress: servers fail to reach an undocumented endpoint and the agent returns partial results instead of errors. Check the proxy deny log before touching the model, the prompt, or the policy — in our experience that is where the first week of problems lives.







Discussion
Loading discussion…