MCP security in production: the controls are yours

9 min readKacper KlusekBy Kacper Klusek

On this page9 sections
Title card reading MCP security: the controls are yours, with a gold-outlined MCP server window on navy listing glowing tool rows, one poisoned row trailing a gold thread that an approval gate stops before it reaches a credential key.

Your agent’s tools now arrive over MCP, and so do its attacks. The current MCP spec names token passthrough, the confused deputy and state handle hijacking and binds implementers with MUSTs; tool poisoning and rug pulls sit outside it, and nearly every control is yours to build. When Enkrypt AI scanned the top 1,000 MCP servers in October 2025, about a third had critical vulnerabilities, and the backdoored postmark-mcp-server had already shipped exfiltration in the wild. This post walks all five attack classes, who the spec binds for each, and the controls a production team builds on top, with the approval wiring we run ourselves.

Tool descriptions are the attack surface

An MCP tool arrives as a name, a JSON schema and a natural-language description, and your model reads all three. Invariant Labs disclosed the core problem in April 2025 as tool poisoning: “malicious instructions are embedded within MCP tool descriptions that are invisible to users but visible to AI models.” Their proof of concept was an innocent-looking add tool whose docstring told the model to read the client’s own MCP config file and pass its contents along as a side note. The client showed the user a math tool; the model saw an exfiltration order.

This is prompt injection with a distribution channel. The description travels inside the protocol, gets concatenated into your agent’s context on every run, and in a multi-server setup a hostile description can even redirect how the model uses a different, trusted server’s tools, the variant Invariant calls shadowing. The practical consequence: treat every tool description as untrusted model input. Show it to a human in full before first use (the disclosure notes that one client’s confirmation dialog hid exactly the part that mattered), run new servers inside a sandbox until reviewed, and diff descriptions on every fetch, not just the first.

A rug pull survives your first approval

The same disclosure named the second class: rug pulls, “where a malicious server can change the tool description after the client has already approved it.” Approval in most clients is a one-time event; the server’s self-description is not.

Four months later the pattern had a CVE. CVE-2025-54136, published in August 2025 and scored 8.8 HIGH by NVD, hit the Cursor editor: in versions 1.2.4 and below, an attacker with write access to a shared repository could silently swap an already-approved MCP configuration for a malicious command, and the editor would run it with no new prompt. Check Point Research, who disclosed it as MCPoison, reported the fix shipping in Cursor 1.3 on July 29, 2025: after the patch, any change to an approved MCP configuration, however small, triggers a fresh approval.

The trap: the first approval is the only one many clients ever show. If your client does not re-prompt on changed tool definitions, that is your control to build: pin servers by version, hash the tool descriptions you approved, and fail closed when the hash moves. Invariant proposed exactly this pinning in the original disclosure.

Timeline of MCP security from April 2025 to July 2026: Invariant Labs discloses tool poisoning and rug pulls, Cursor 1.3 ships the MCPoison fix, NVD publishes CVE-2025-54136 at 8.8 HIGH, Enkrypt AI finds a third of the top 1,000 servers critically vulnerable, and spec revision 2026-07-28 removes protocol-level sessions.
Fifteen months from disclosure to a redesigned spec. Each stop is dated from the page that published it.

Token passthrough and the confused deputy are server-side sins

Two attack classes live on the server side of the protocol, and if you ship an MCP server, the spec binds you directly. The security best practices page is blunt about the first: “MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server.” Token passthrough, forwarding a client’s token to a downstream API without validating its audience, is explicitly forbidden in the authorization specification, because a server that launders tokens becomes a proxy for data exfiltration and breaks every audit trail downstream. That matters doubly when the credential belongs to an agent rather than a person: a non-human identity rarely has anyone watching its token’s travel.

The confused deputy is subtler: an MCP proxy server holding a static client ID with a third-party API can be tricked into using its standing consent to mint authorization codes for a malicious client. The spec’s mitigation is per-client consent and a registry of approved client IDs per user. Note the baseline underneath both: the authorization spec opens with “Authorization is OPTIONAL”. A server with no auth at all is spec-compliant, which is how the same scan that put roughly 33% of the top 1,000 servers in the critical bucket could also report that “Most MCP servers have no authentication, no rate limiting, no logging, and no audit trails.” Proper agent authorization is table stakes you have to put on the table yourself.

The current spec is stateless, and state handles are the new session

MCP’s spec moves fast enough that the attack names change. The current protocol version is 2026-07-28, and the best practices page above replaces the old session hijacking section outright: “MCP is stateless and has no protocol-level sessions.” A server that needs state across requests now mints an explicit handle, a cart ID, a workflow ID, and receives it back as an ordinary tool argument. The new attack is state handle hijacking: an attacker who obtains or guesses a handle reads or modifies another user’s state. The mitigations are the ones sessions always needed, non-guessable identifiers bound to the authenticated user and verified on every request, but they now sit in your tool-argument handling, where it is easy to assume the value is benign because your own server minted it. We unpacked why the protocol dropped persistent sessions in our stateless MCP glossary entry; the security consequence is that every MUST in the MCP spec is a test you have to run against your own stack, not a guarantee you get to assume.

The five attacks, and who owns the fix

The spec’s normative language binds servers, clients and proxies unevenly, and none of it executes itself. This is the division of labor as the cited pages state it:

Attack What happens The spec binds What you build
Tool poisoning Hidden instructions in tool descriptions steer the model Nobody (trust model gap) Description review, sandboxing, cross-server boundaries
Rug pull Approved tool definition changes after approval Nobody (client behavior) Version pinning, description hashes, re-approval on change
Token passthrough Server forwards tokens it never validated Servers (MUST NOT) Audience validation, per-service credentials
Confused deputy Proxy’s static consent mints codes for attackers Proxies (MUST) Per-client consent, client ID registry
State handle hijacking Guessed handle reaches another user’s state Servers (MUST) Non-deterministic handles bound to the user, verified per request

Gate tools on what the server declares, then distrust the declaration

Frameworks are starting to carry the approval wiring for you. LangChain’s MCP integration connects agents to servers through a single MCPAdapter (beta since 1.4.0), and its MCP tools page shows the pattern we consider the floor for production: read the server’s own annotations off each tool and gate on them, rather than hardcoding tool names. An MCP server can mark a tool destructive; the human-in-the-loop middleware turns that into a pause:

from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain.agents.middleware.human_in_the_loop import (
    InterruptOnConfig,
)

def needs_approval(request) -> bool:
    meta = getattr(request.tool, "metadata", None) or {}
    ann = (meta.get("mcp", {}).get("tool", {})
           .get("annotations", {}))
    return ann.get("destructive_hint", True)

gate = InterruptOnConfig(
    allowed_decisions=["approve", "reject"],
    when=needs_approval,
)

When the predicate gates a call, the run pauses as a LangGraph interrupt, the same human-in-the-loop machinery we dissected in LangGraph human-in-the-loop: interrupt vs middleware, and a person approves or rejects before anything executes. Two cautions. First, default to gating when the hint is absent, as the True fallback above does: an unannotated tool is unknown, not safe. Second, the hint is the server’s self-description, and a poisoned server will describe itself politely. Annotation-based tool approval is a usability layer that routes human attention; the trust decision about the server itself happens earlier, at install, pin and review time. We walked the adapter, elicitation and what did not survive the migration in MCP is now built into LangChain.

What we run: no tokens in the agent, approvals at the edge

Our own production setup treats the agent’s environment as already compromised and prices the controls by blast radius. The scheduled agents that research, write and publish for this site run six days a week in cloud containers, and the credentials they use never enter those containers: each secret is attached at an egress proxy, per host, so the agent sends requests with no Authorization header of its own. A poisoned tool description can ask our agent for its tokens all day; there is no token in the environment to hand over. That is least privilege applied to credentials rather than permissions, and it converts the worst tool-poisoning outcome from stolen credentials into scoped misuse we can read in the proxy log. OpenAI’s disclosure of agents weakening their own oversight makes the same argument from inside the model lab: OpenAI’s agents disabled their own monitoring.

Destructive actions take the other half: anything that writes somewhere a rollback cannot reach runs behind an approval gate wired exactly as in the snippet above, and new MCP servers get read first, the description, the schema, the repository, before any agent loads them. None of this is exotic, which is the point. AI agent security in 2026 is mostly the boring parts of ordinary security, applied at the new trust boundary the protocol created, and the boundary work stays yours even when the harness is managed, the line we drew in Claude Managed Agents: the harness is now a service.

The takeaway

MCP gave every agent stack the same connector, which means it gave every attacker the same surface. The spec’s current revision names the attacks and assigns the MUSTs, but the trust-model gaps, poisoned descriptions and rug pulls, belong to no one but the team shipping the agent. Pin and hash what you approved, validate token audiences, treat state handles as hostile input, gate destructive tools on annotations with an approval default of no, and keep credentials out of the agent’s reach entirely. The protocol will keep moving; the controls above survive each revision because they assume the worst of every party the spec politely binds.

Sources

See this working in a system we built