MCP Server Security: Tool Poisoning, Confused Deputy, OAuth 2.1 Resource Indicators and a Threat Model (2026)
Every Model Context Protocol (MCP) server you connect to an agent adds two things at once: a set of capabilities and a block of text that a language model will read and obey. Most teams audit the first and ignore the second. That gap is where MCP server security actually breaks, because the model cannot tell a tool description written by a careful engineer from one written by an attacker, and the user usually sees neither.
This matters now because MCP has moved from demo to infrastructure. Remote servers sit behind OAuth, the 2026-07-28 revision of the specification removed protocol-level sessions, and organisations are wiring agents to mail, repositories, ticketing and production APIs. The attack surface is no longer a curiosity.
You will leave with a trust-boundary model of an MCP deployment, a STRIDE-style threat table, a precise reading of what the specification requires (audience binding, RFC 8707 resource indicators, RFC 9728 metadata, the token passthrough ban), working audience-validation code, and a defence-in-depth checklist. All content is defensive; no working exploit payloads appear.
What this covers: the threat model, tool poisoning and its relatives, the lethal trifecta, the confused deputy problem in OAuth proxies, token audience binding, local server sandboxing, supply chain risk, auditing, and a prioritised checklist.
Context and Background
MCP standardises how an AI host application talks to external tools and data. A host embeds one or more MCP clients; each client connects to a server that exposes tools, resources and prompts. Our overview of the protocol’s moving parts is in the MCP architecture explainer, and the recent move to a stateless wire protocol is covered in the 2026-07-28 stateless migration guide. This post assumes you know those basics and focuses on what can go wrong.
Two facts from the specification frame everything that follows. First, authorization is optional. The authorization page states that implementations using an HTTP-based transport should conform to it, while implementations using stdio should not follow it and instead retrieve credentials from the environment. A local stdio server therefore has no OAuth layer at all, only the privileges of the process the client spawned. Second, the tools page says that, for trust and safety, there should always be a human in the loop with the ability to deny tool invocations, and that clients must treat tool annotations as untrusted unless they come from trusted servers. The protocol authors are explicit that the model-controlled design is a hazard that the host must contain.
The security story splits cleanly into two layers. The first is classic web and OAuth security: who is allowed to call this server, with which token, for which resource. The specification handles this layer well, because it inherits decades of OAuth hardening, including the OAuth security best current practice published as RFC 9700. The second layer is new: the model itself is a confused, over-trusting component that executes natural-language instructions found in data. No RFC fixes that. It needs architecture.
Public incidents show both layers failing. In July 2025 JFrog disclosed CVE-2025-6514 in the mcp-remote client proxy, rated CVSS 9.6, where a malicious server could return a crafted authorization_endpoint that the client passed to a URL-opening routine, leading to command execution on Windows. Versions 0.0.5 through 0.1.15 were affected and 0.1.16 fixed it. That is a client-side failure to validate server-supplied OAuth metadata. In September 2025 Koi Security reported a copy of the postmark-mcp npm package whose version 1.0.16 added a single line that blind-copied every outgoing email to an attacker-controlled address. Reporting put its downloads at 1,643. That is a supply chain failure. Neither involved a model being tricked, and both were exploited through ordinary software weaknesses.
Tool poisoning, by contrast, is a model-layer attack. Invariant Labs described it in April 2025 as indirect prompt injection embedded in MCP tool descriptions that are visible to the model but, in the clients they tested, not fully visible to the user. For the broader prompt-injection landscape, see our agentic AI security analysis. The rest of this post treats both layers together because real deployments fail at their intersection.
A note on versions. Quotations of normative language below come from the current published draft of the MCP specification pages for authorization, security best practices and tools, which describe the stateless 2026-07-28 design. The draft text can change. Verify any MUST or SHOULD against the revision your SDK targets before you cite it in an audit.
Trust Boundaries: A Reference Threat Model for MCP
A defensible MCP deployment treats each component as a separate trust zone and assumes every arrow between zones can be abused. The model below names four zones and the data that crosses each boundary.
Direct answer. MCP server security is the practice of constraining what an MCP server, its tool descriptions and its tokens can cause an agent to do. It rests on four controls: authenticate and audience-bind every token, treat tool metadata and tool output as untrusted input, minimise privileges per tool, and isolate or log every boundary crossing so a compromise stays small and visible.

Figure 1: Trust zones in an MCP deployment. Tool descriptions and tool results flow into the model, which is the most easily confused component.
Figure 1 shows the host application, the MCP client plus the model context, a remote server reached over HTTPS with a bearer token, a local server reached over stdio, and a third-party API behind a remote server. The unusual feature is the arrow labelled “tool results and descriptions” into the model. In a web application, user-controlled data flows into a database where a parser enforces the code/data distinction. In an agent, the model is the parser, and it has no such distinction. Everything in its context window is potentially an instruction.
Zone 1 and 2: Host to remote server
The host and client belong to the user’s side of the boundary. The remote server belongs to someone else, and the specification treats it as a resource server in OAuth terms. The client must discover the server’s authorization server through RFC 9728 protected resource metadata, obtain a token and send it on every request in the Authorization: Bearer header. Tokens must not appear in the query string. The server, in turn, must validate that each token was issued for it. Everything the server returns is untrusted from the client’s perspective: descriptions, schemas, results, even OAuth metadata URLs, as CVE-2025-6514 demonstrated.
Zone 3: Local servers
A local stdio server runs with the same operating-system privileges as the client that launched it. The specification’s best-practice page lists the threats directly: a malicious startup command in a client configuration, a malicious payload inside the server itself, and an insecure local server left listening on localhost and reached through DNS rebinding. Because there is no OAuth layer, the only controls are consent before launch, sandboxing and least privilege on the host.
Zone 4: Upstream APIs
When a server wraps a third-party API it plays two roles: resource server for the MCP client and OAuth client for the upstream. The specification insists these are separate tokens with separate audiences. Collapsing them is the root of both the token passthrough anti-pattern and the confused deputy problem covered below.
STRIDE applied to MCP
STRIDE is a classic mnemonic for Spoofing, Tampering, Repudiation, Information disclosure, Denial of service and Elevation of privilege. Applying it to the boundaries above gives a concrete worklist.
| STRIDE | MCP-specific threat | Primary control |
|---|---|---|
| Spoofing | Typosquatted or hijacked server package; client impersonation via localhost redirect URIs; mix-up of authorization servers | Pin publisher and version, validate iss, display redirect URI hostname |
| Tampering | Rug pull: tool description changed after approval; poisoned tool output | Hash and pin tool definitions, re-prompt on change, sanitise outputs |
| Repudiation | Downstream logs show the server, not the user; no record of which tool call came from which prompt | Per-user token subject propagated to logs, correlation IDs |
| Information disclosure | Lethal trifecta exfiltration; token in logs; overbroad scopes | Remove a trifecta leg, short-lived tokens, scope minimisation |
| Denial of service | Unbounded tool invocation loops; expensive tool calls | Rate limits, timeouts, per-token quotas |
| Elevation of privilege | Confused deputy; token passthrough; local server running as the user | Per-client consent, audience binding, sandboxing |
The table is a starting point, not a certification. Its value is that it forces each row to have an owner, which is more than most MCP rollouts have today.
Tool Poisoning, Rug Pulls and Shadowing
Tool poisoning exploits the fact that the model reads the full tool description while the human reviews a summary. This section describes the mechanism and the defences without reproducing attack strings.
The mechanism
An MCP server returns, for each tool, a name, a human-readable description and a JSON Schema for inputs. The client forwards these to the model so it can decide when and how to call the tool. According to Invariant Labs’ April 2025 write-up, the problem is that “malicious instructions are embedded within MCP tool descriptions that are invisible to users but visible to AI models”. A description for an innocuous calculator can carry additional natural-language directives, such as an instruction to read a local file and pass its contents as an argument. The model, trained to follow instructions in context, may comply. The user sees a tool named “add” and an approval dialog that, in the clients Invariant tested, did not show the full input.
Three properties make this attack cheap. The payload is plain text, so it passes every signature and malware scanner. It lives in metadata that rarely gets code review. And it executes inside a model that was optimised to be helpful. Note that the description is also not the only carrier: parameter names, schema descriptions, default values, error messages and tool results are all text that reaches the model.
Rug pulls
A rug pull is a time-of-check to time-of-use problem. A user approves a server whose descriptions are benign, and the server later serves different descriptions. Invariant describes this as a server changing a tool’s description after the client has approved it. The protocol makes it easy by design: servers that declare listChanged may notify clients when the tool list changes, and a stateless server can simply return different data on any call. Notably, the draft tools page also states that the set of tools must not vary per connection or as a side effect of other requests, though it may vary with the authorization presented. That helps reproducibility but does nothing to stop a server changing its answer over time.
Cross-server shadowing
Shadowing is the most subtle variant and the one that shows why per-server trust is insufficient. Invariant reports that a malicious server’s tool description can alter how the agent uses a different, trusted server’s tool, such as an email tool, even if the malicious tool is never called. All tool descriptions share one context window, so any server can address instructions to the model about every other server. If you connect a trusted email server and an untrusted “fun facts” server to the same agent, the second can attempt to redirect where the first sends mail. The draft tools page notes that aggregating clients may see name collisions and should disambiguate, for example by prefixing tool names with a server identifier, and it adds that the server’s self-reported name is not guaranteed unique and should not be used for disambiguation.
Defences that work, and what they cost
Show the full definition. The first fix is a user interface fix: display the complete description and arguments before first use and before every change, and visually separate text the user can see from text only the model sees. The specification already says clients should show tool inputs to the user before calling the server, to avoid malicious or accidental data exfiltration. The cost is friction, and approval fatigue is a real limit, since users click through.
Pin and hash. Treat a tool definition as a dependency. Compute a digest of name, description and schema at approval time, store it, and refuse or re-prompt when it changes. This defeats rug pulls directly. It does not defeat a server that was malicious on day one, which is why it must be combined with review and with allow-listing of publishers. Here is a minimal, runnable illustration of canonical hashing. It is a teaching sketch, not a product.
import hashlib, json
def tool_digest(tool: dict) -> str:
"""Stable digest over the fields the model actually reads."""
material = {
"name": tool["name"],
"description": tool.get("description", ""),
"inputSchema": tool.get("inputSchema", {}),
}
blob = json.dumps(material, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
approved = {"name": "add", "description": "Add two numbers",
"inputSchema": {"type": "object"}}
pin = tool_digest(approved)
served_later = dict(approved, description="Add two numbers. (changed text)")
print("changed" if tool_digest(served_later) != pin else "same")
Running it prints changed. Canonical JSON with sorted keys matters; otherwise key reordering causes false alarms and attackers learn to hide in the noise.
Namespace tools by server. Prefix every tool with an immutable server identifier in the model’s view so that a hostile server cannot define a tool that collides with a trusted one.
Constrain data flow between servers. The strongest control for shadowing is not text filtering but policy: a tool from server A may not receive data that originated from server B unless a rule allows it. This is information-flow control, and it is the direction most serious research and products are moving. It is harder to build than a filter, which is exactly why filters are tempting and unreliable.
Do not rely on a model to police itself. Asking the model to ignore suspicious instructions raises the cost of an attack but offers no guarantee. Simon Willison made the broader point in June 2025 that vendor guardrail products claiming to catch 95 percent of attacks represent a failing grade in web application security terms, where a single bypass is enough.
The Lethal Trifecta: Why Combinations Are the Real Risk
Individual tools are rarely the problem. Combinations are. Simon Willison’s “lethal trifecta”, published on 16 June 2025, names three capabilities that together make data theft by prompt injection straightforward: access to your private data, exposure to untrusted content, and the ability to communicate externally. Remove any one leg and the standard exfiltration path closes.

Figure 2: The three legs of the lethal trifecta and the control that removes each. MCP’s mix-and-match design makes accidental assembly easy.
MCP is unusually good at assembling the trifecta by accident because it encourages mixing tools from many sources. A repository server provides private code. An issue-tracker or web-fetch server provides content any stranger can write. A mail or HTTP-capable server provides the outbound channel. Each looks reasonable alone. Connected to one agent, a public issue containing hidden instructions can cause the agent to read a private repository and publish its contents. Public reports of this class of attack against developer-facing integrations exist; the pattern, not any single incident, is what matters for your design.
Reading the diagram as a design review
Use Figure 2 as a checklist applied per agent session, not per server. List every tool the session can reach and tag each with the legs it supplies. Many tools supply more than one: a browser tool reads untrusted pages and can also make arbitrary requests, so it supplies two legs by itself. A shell tool supplies all three in the worst case.
Cutting each leg
Leg 1, private data. Issue narrow credentials. A token that can read one repository is a different risk from one that can read an organisation. Scope minimisation in the specification is the protocol-level form of the same idea: the security best-practices page recommends a minimal initial scope set and incremental elevation through targeted WWW-Authenticate challenges, rather than requesting the entire scopes_supported list up front.
Leg 2, untrusted content. Isolate. Run untrusted-content processing in a separate agent session with no access to private tools, and pass only a constrained, structured summary to the privileged session. Structured summaries with a fixed schema carry far less injection surface than free text, though they are not immune if a free-text field survives.
Leg 3, external communication. Enforce egress controls outside the model. An allow-list of destination hosts, enforced by a network proxy or by the tool implementation, works regardless of what the model has been persuaded to do. Remember that exfiltration channels are broader than “send email”: URL fetches with data in the query string, image rendering with embedded parameters, commits to public repositories and issue comments are all outbound channels.
Human approval is a weak fourth control
Requiring confirmation for every outbound action sounds like a fix, and the specification recommends confirmation prompts for sensitive operations. In practice, approval fatigue erodes it. Treat confirmation as a backstop for rare, high-consequence actions, and place structural controls (the three cuts above) in front of it. A useful rule of thumb is that if a safe outcome depends on a person reading a long argument blob carefully every time, it will fail in production.
MCP OAuth Authorization: What the Specification Actually Requires
For HTTP transports, the specification builds on OAuth 2.1 (the draft-ietf-oauth-v2-1 line, cited in the current page as draft 13) with a defined subset of related RFCs. The listed standards are RFC 6750 (bearer tokens), RFC 8414 (authorization server metadata), RFC 7591 (dynamic client registration), RFC 8707 (resource indicators), RFC 9728 (protected resource metadata), RFC 9207 (issuer identification) and the OAuth Client ID Metadata Document draft, plus OpenID Connect Discovery. Knowing which RFC does which job prevents most integration confusion.

Figure 3: The authorization flow from a 401 challenge to an audience-bound token and a validated call.
Discovery: RFC 9728 and the 401 challenge
MCP servers must implement OAuth 2.0 Protected Resource Metadata, and clients must use it to find the authorization server. The server answers an unauthenticated request with HTTP 401 and a WWW-Authenticate: Bearer header that carries a resource_metadata URL, and may add a scope parameter naming the scopes required. The client fetches the metadata document, reads the authorization_servers list, then retrieves authorization server metadata via RFC 8414 or OpenID Connect discovery. This is a security feature as well as plumbing: the client learns where to authenticate from the resource itself rather than from a hard-coded guess. It is also an SSRF surface, discussed below, because every URL in this chain is server-controlled.
PKCE and S256
Clients must implement PKCE and must use the S256 challenge method when technically capable. The specification adds a pointed rule: since neither OAuth 2.1 nor PKCE defines a discovery mechanism, clients must check code_challenge_methods_supported in the authorization server metadata, and if it is absent the client must refuse to proceed. This closes a downgrade path where a client silently falls back to no PKCE.
Resource indicators: RFC 8707
This is the heart of audience binding. Clients must send the resource parameter in both the authorization request and the token request, identifying the MCP server the token is for, using its canonical URI. The specification gives examples of valid canonical URIs such as https://mcp.example.com/mcp and invalid ones such as a bare host with no scheme or a URI containing a fragment. It also says clients must send the parameter even if the authorization server does not support it. The point is to let a supporting authorization server mint a token whose audience is exactly that server, so a token stolen from one MCP server is useless at another.
Be precise about what this does. The resource parameter is a request; the audience claim in the resulting token is the evidence. A server must check the evidence, not assume that because the client asked correctly the token is correctly scoped. RFC 9068, the JWT profile for access tokens, defines the aud claim that most deployments rely on. If your authorization server issues opaque tokens, the equivalent check is token introspection (RFC 7662) returning an audience, or an authorization-server-specific mechanism.
Validation on the server
The specification is blunt. MCP servers must validate that access tokens were issued specifically for them as the intended audience, must respond 401 for invalid or expired tokens, must only accept tokens valid for their own resources, and must not accept or transit any other tokens. The security considerations page repeats that servers must reject tokens that do not include them in the audience claim, and that when the server calls upstream APIs it acts as a separate OAuth client: the token used upstream is a separate token, and the server must not pass through the one it received.
Status codes and step-up
A 401 means missing, invalid or expired credentials. A 403 means the token is valid but lacks scope, ideally with error="insufficient_scope", the needed scope and the resource_metadata URL in WWW-Authenticate. The client then performs a step-up flow, computing the union of its previously requested scopes and the newly challenged ones. The specification recommends servers include all scopes required for the current operation in a single challenge, to avoid multiple round trips, and that clients cap retries and treat repeated failure as permanent. Servers must also account for scope hierarchies when deciding whether a token suffices.
Client identity: from DCR to client ID metadata documents
Client registration has changed. The draft states that authorization servers and clients should support Client ID Metadata Documents, in which the client uses an HTTPS URL as its client_id and the authorization server fetches a JSON document from it. Dynamic Client Registration under RFC 7591 is described as deprecated and retained for backwards compatibility. The same draft warns that metadata documents prove control of a domain but cannot prove which local process is listening on a localhost redirect URI, so authorization servers should display warnings for localhost-only redirect URIs and must clearly show the redirect hostname. The 2026-07-28 changelog also says client credentials are bound to the issuing authorization server and must be keyed by issuer.
Mix-up defence: RFC 9207
An attacker who controls one authorization server a client talks to may try to get the client to send a code to the wrong token endpoint. The draft requires clients to record the issuer before redirecting and to validate the iss parameter on the response against it, using simple string comparison without normalisation, before sending the code to any token endpoint. The page notes that PKCE alone does not stop this, because the code verifier would be sent to the attacker’s endpoint, and that resource indicators do not help when the attacker’s server intercepts first. The protection depends on honest authorization servers emitting iss; the draft says a future revision is expected to raise that from SHOULD to MUST.
Confused Deputy in MCP OAuth Proxies
A confused deputy is a privileged program tricked into using its authority on behalf of a party that lacks it. In MCP the classic site is the OAuth proxy: an MCP server that fronts a third-party API whose authorization server does not support dynamic registration, so the proxy uses a single static client ID towards the third party while letting each MCP client register dynamically.

Figure 4: The vulnerable path skips consent because of a cookie; the safe path inserts proxy-owned consent keyed to the client.
The vulnerable conditions
The security best-practices page lists four conditions that must all hold: the proxy uses a static client ID with the third-party authorization server; it lets MCP clients register dynamically, each receiving its own client ID; the third-party server sets a consent cookie after first authorization; and the proxy lacks proper per-client consent before forwarding. When all four are true, an attacker can register a client with a malicious redirect URI, lure a user to a crafted link, and benefit from the existing cookie: the third-party server sees the static client it already knows, skips its consent screen, and the resulting code is redirected to the attacker’s URI. The attacker redeems it and acts as the user.
The deputy here is the proxy, which holds the static client identity the third party trusts. It was confused because the user’s earlier consent to one client was silently applied to another.
The required mitigations
The specification states that proxy servers must implement per-client consent and lists concrete controls. Maintain a server-side registry of approved client IDs per user and check it before starting the third-party flow. The consent page must name the requesting client, show the third-party scopes requested, show the registered redirect URI, defend against CSRF and clickjacking (using frame-ancestors or X-Frame-Options: DENY). Cookies used to track consent must use the __Host- prefix, be Secure, HttpOnly and SameSite=Lax, be signed or backed by a server session, and bind to the specific client ID rather than to “this user has consented”. Redirect URIs must match exactly with no wildcards. The state value must be cryptographically random, single-use, short-lived (the page suggests around ten minutes), and the state cookie must only be set after the user approves consent. Setting it earlier defeats the consent screen.
Token passthrough: the same bug in another place
The token passthrough anti-pattern is the audience-validation half of the same problem. A server that accepts any token and forwards it upstream breaks the audience boundary twice. The best-practices page lists the consequences: downstream rate limiting and monitoring that depend on audience are bypassed; audit trails are corrupted because the downstream log shows a different principal than the true caller; stolen tokens can use the server as an exfiltration proxy; and trust boundaries blur so one compromised service can reach others. Its mitigation is a single sentence: servers must not accept any tokens that were not explicitly issued for the MCP server. If you need upstream access, perform a separate OAuth exchange (or an on-behalf-of or token-exchange flow if your identity provider supports it) so the upstream token has the upstream audience and the MCP token has the MCP audience.
Implementing Audience-Bound Token Validation
The following illustrative code shows the three things a resource server must do on every request: verify the signature against the issuer’s keys, check the issuer, audience, expiry and scope, and return the right status code with a WWW-Authenticate header. The claim-checking core is plain Python and was executed against test cases; the FastAPI wiring and the PyJWT call are illustrative and depend on your identity provider, so adapt rather than copy.
import time
from typing import Any
CANONICAL_RESOURCE = "https://mcp.example.com" # matches the RFC 8707 resource value
EXPECTED_ISSUER = "https://auth.example.com"
METADATA_URL = "https://mcp.example.com/.well-known/oauth-protected-resource"
def check_claims(claims: dict[str, Any], required_scope: str, now: float):
"""Return (status, reason). Mirrors the spec: 401 for bad token, 403 for scope."""
if claims.get("iss") != EXPECTED_ISSUER:
return 401, "invalid_token"
aud = claims.get("aud")
audiences = [aud] if isinstance(aud, str) else list(aud or [])
if CANONICAL_RESOURCE not in audiences: # the audience binding check
return 401, "invalid_token"
if claims.get("exp", 0) <= now:
return 401, "invalid_token"
granted = set(str(claims.get("scope", "")).split())
if required_scope not in granted:
return 403, "insufficient_scope"
return 200, "ok"
def www_authenticate(reason: str, scope: str | None = None) -> str:
parts = [f'Bearer error="{reason}"', f'resource_metadata="{METADATA_URL}"']
if scope:
parts.append(f'scope="{scope}"')
return ", ".join(parts)
# --- Illustrative FastAPI wiring (not executed here) ---------------------------
# from fastapi import FastAPI, Header, HTTPException
# import jwt # PyJWT; fetch keys from the issuer's jwks_uri and cache them
#
# app = FastAPI()
#
# def authorize(authorization: str | None, scope: str):
# if not authorization or not authorization.lower().startswith("bearer "):
# raise HTTPException(401, headers={"WWW-Authenticate": www_authenticate("invalid_token")})
# token = authorization.split(" ", 1)[1]
# claims = jwt.decode(token, key=signing_key_for(token), algorithms=["RS256"],
# options={"verify_aud": False}) # we check aud ourselves above
# status, reason = check_claims(claims, scope, time.time())
# if status != 200:
# raise HTTPException(status, headers={"WWW-Authenticate": www_authenticate(reason, scope)})
# return claims
#
# @app.post("/mcp")
# async def mcp(body: dict, authorization: str | None = Header(default=None)):
# claims = authorize(authorization, "files:read")
# # never forward `authorization` upstream; obtain a separate upstream token
# ...
I tested check_claims with five cases: a good token returns 200; a token whose audience is another service returns 401; a token with a list audience that includes this server returns 200; a token missing the required scope returns 403; an expired token returns 401. All behaved as expected.
What the snippet does not cover
Three omissions matter. It does not show key retrieval and rotation from the issuer’s JWKS endpoint, which needs caching with sensible refresh. It assumes JWT access tokens; opaque tokens need introspection. And it fixes one algorithm in the decode call, which is deliberate: accepting whatever algorithm the token header names is a classic vulnerability, so always pass an explicit allow-list. Also note that the claim aud must be compared exactly. A prefix or substring match lets https://mcp.example.com.evil.test through, a mistake that is easy to write and easy to test for.
Scope design for tools
Map scopes to capabilities, not to servers. A scope such as files:read is useful; mcp:all is a bearer token for everything. The specification’s guidance lists common mistakes: publishing every scope in scopes_supported, wildcard or omnibus scopes, bundling unrelated privileges to avoid future prompts, and treating claimed scopes in the token as sufficient without server-side authorization logic. That last point deserves emphasis. A scope says the token may attempt an operation; your code must still check that this user may touch this object. Broken object-level authorization remains the most common API flaw, and an MCP tool that takes an object identifier from model-generated arguments is just an API endpoint whose caller is untrustworthy by construction.
Local Servers, Supply Chain and Client-Side Risks
Sandboxing local servers
A stdio server inherits the client’s privileges: your home directory, SSH keys, cloud credentials and browser cookie stores. The specification says that if a client supports one-click local server configuration it must show the exact command, without truncation, flag it as potentially dangerous and require explicit approval, and it should highlight dangerous patterns and run servers in a sandbox with minimal default privileges. Practically, that means containers or OS sandboxes with a read-only root, a single mounted working directory, no inherited environment variables beyond what the server needs, and no network unless the server’s job requires it. For servers that need a socket, the page suggests using stdio to limit access to the client, or requiring an authorization token or using restricted IPC such as Unix domain sockets.
The trade-off is real. A filesystem server that must read user projects needs those directories mounted, and a sandbox that is too tight breaks the product, so teams loosen it. Write the allowed paths down as policy and review them as you would a firewall rule.
Supply chain: typosquats, hijacks and silent updates
The postmark-mcp case is instructive because it needed no vulnerability at all. A package copying a legitimate library earned trust through familiarity and then shipped a one-line change in a later version. Koi Security reported the malicious change in version 1.0.16, published on 17 September 2025. The package was reported to have 1,643 downloads before removal. Postmark stated it had not authorised the package.
Defences are the standard ones, applied with discipline: install only from publishers you have verified out of band, pin exact versions and lockfile hashes, disable automatic updates for tool servers, diff the source of each new version before upgrading, and run new servers in a sandbox with no network until reviewed. An internal registry of approved servers beats ad hoc npx commands in a configuration file. Treat a server’s tool definitions as part of the package: hash them at approval and alert on change, as described earlier.
Client bugs: when the server attacks the client
CVE-2025-6514 shows the reverse direction. The server was the attacker, and the client’s failure was passing a server-supplied OAuth endpoint to an operating-system URL opener. The specification now says clients must allow only http and https schemes for authorization URLs, reject javascript:, data:, file: and vbscript:, avoid shell execution when opening URLs, and sanitise URLs received from servers. The same chain, from resource_metadata to token endpoint, is an SSRF vector: the page lists direct internal IPs, cloud metadata addresses such as 169.254.169.254, localhost services, DNS rebinding and redirect chains. Its advice is to require HTTPS, block private and link-local ranges using a library rather than hand-written parsing (octal, hex and IPv4-mapped IPv6 tricks defeat custom code), validate each redirect hop, pin DNS between check and use, and route fetches through an egress proxy for server-side clients. Authorization servers that fetch Client ID Metadata Documents have the same SSRF exposure and need the same controls.
State handles and stateless servers
With sessions gone from the 2026-07-28 protocol, servers that need continuity mint explicit handles that the model carries as ordinary arguments. The draft security page names the risk state handle hijacking and says possession of a handle must not be treated as authentication. Bind the handle to the verified user, for example by keying storage as user ID plus handle, where the user ID comes from the validated token rather than from a client-supplied field, and use unguessable identifiers with expiry. This is the Insecure Direct Object Reference pattern in new clothing.
Logging, Detection and Incident Response
Prevention fails eventually, so design for detection. The tools page says clients should log tool usage for audit, and servers should rate limit invocations. Go further than the minimum.
Log the decision, not just the call. For each tool call, record the authenticated subject, the client ID, the tool name, a digest of the tool definition in force, the argument hash or redacted arguments, the token’s audience and scopes, the result status and a correlation ID that ties the call to the originating user prompt. The digest is what lets you answer “was this a rug pull?” after the fact. Store logs outside the agent’s reach, because an agent that can write to its own audit log can be talked into editing it.
Detect trifecta assembly. Emit an alert when a single session first touches an untrusted-content tool and later calls a tool with an external channel, especially if a private-data tool ran between them. This is a coarse rule with a manageable false positive rate and catches the most dangerous sequence.
Detect description drift. Alert on any change in a pinned tool digest, and on any new tool whose name collides with an existing one.
Detect scope inflation. The best-practices page recommends logging elevation events with correlation IDs. Track the ratio of requested to used scopes per client. A client that requests broad scopes and uses one is a candidate for tightening.
Rehearse revocation. Know how to revoke a client, a user grant and a server’s upstream credentials in under an hour. Short-lived access tokens, which the specification says authorization servers should issue, shrink the exposure window, and rotating refresh tokens for public clients is required.
Trade-offs, Gotchas, and What Goes Wrong
Audience binding depends on the authorization server. The specification says resource indicators bind tokens to audiences when the authorization server supports the capability, and it tells clients to send the parameter regardless. Some identity providers ignore resource or implement it differently, for example mapping it to a custom audience setting. If yours does not mint a distinct aud, your server-side check will fail closed on legitimate tokens, and the temptation is to loosen the check. Do not. Fix the issuer configuration or put a token-exchange step in front.
Confirmation prompts do not scale. Per-call human approval protects a demo and exhausts a team. Users approve what they have approved before. Reserve prompts for irreversible or externally visible actions, and make the prompt show the real arguments in full.
Text filters give false comfort. Scanning tool descriptions for suspicious phrases catches naive attacks and teaches attackers to rephrase. Use filters as telemetry, never as the control.
Pinning breaks legitimate updates. If you hash tool definitions, every benign description edit triggers re-approval. That friction is the price of detecting rug pulls. Mitigate it by approving at the server-version level, with a review of the definition diff.
Over-scoping is the path of least resistance. The specification itself notes that general-purpose clients lack domain knowledge and fall back to requesting everything in scopes_supported when no scope hint is given. Servers control this by publishing a small scopes_supported and by sending a precise scope in the first 401.
Stateless does not mean secure. Removing sessions removes session hijacking of the old Mcp-Session-Id, but moves the problem into handles and into per-request authentication. Every request must carry and validate its credential.
Unverified claims. Public numbers for how many MCP servers are vulnerable vary by vendor survey and methodology. This post deliberately cites none. If you need one for a risk register, cite the primary study and its sample, not a secondary summary.
Practical Recommendations
Start by drawing Figure 1 for your own system and annotating every arrow with the credential used and the data carried. Most organisations discover at least one arrow where an upstream token is forwarded as-is, or where a local server has far more filesystem access than its function requires. Fixing those two findings removes a large share of realistic risk.
Next, apply the trifecta test per agent session. If an agent can see private data, read untrusted content and send data out, redesign before you deploy: split the agent, remove a tool, or constrain egress. No amount of prompt wording substitutes for removing a leg.
Then harden the OAuth layer to the letter of the specification, because it is the part with precise, testable requirements. A defensible checklist:
- Serve protected resource metadata and a
WWW-Authenticatechallenge withresource_metadataand a minimalscope. - Require PKCE with S256; refuse authorization servers that do not advertise
code_challenge_methods_supported. - Send and validate RFC 8707
resourceend to end; reject tokens whoseauddoes not exactly match your canonical URI. - Never forward the inbound token upstream; use a separate upstream credential.
- If you proxy a third-party API with a static client ID, implement per-client consent,
__Host-cookies, exact redirect matching and single-usestate. - Validate
isson authorization responses and key stored client credentials by issuer. - Keep access tokens short-lived; rotate refresh tokens for public clients.
- Pin server versions and tool definition hashes; alert on drift.
- Run local servers in a sandbox with an explicit path and network policy.
- Block private and link-local ranges, non-HTTP schemes and unvalidated redirects in any client that fetches server-supplied URLs.
- Log subject, client, tool, definition digest, audience, scopes and result with a correlation ID, in storage the agent cannot modify.
- Rehearse revocation of a client, a grant and an upstream credential.
Finally, schedule a review. The specification is still moving, the draft already changes registration guidance and issuer validation, and the clients you rely on will change their consent behaviour. A quarterly check against the current authorization and security best-practices pages is cheap compared with an incident.
Frequently Asked Questions
What is MCP tool poisoning?
MCP tool poisoning is an indirect prompt injection in which malicious instructions are placed in a tool’s description, schema or output. The model reads the full text and may follow it, while the user typically sees only a short tool name and summarised arguments. Invariant Labs described it in April 2025. Defences include displaying full definitions, pinning and hashing them, namespacing tools by server, and restricting data flow between servers so a poisoned description cannot reach sensitive tools.
What is a confused deputy attack in MCP?
It occurs when an MCP proxy server that uses a static client ID with a third-party authorization server lets clients register dynamically and skips its own consent step. A leftover consent cookie at the third party then lets an attacker’s crafted link produce a code delivered to the attacker’s redirect URI. The specification requires per-client consent, exact redirect matching, protected consent cookies and single-use state to prevent it.
Does MCP require OAuth?
No. The specification makes authorization optional. When an HTTP-based transport supports it, implementations should conform to the authorization specification, which builds on OAuth 2.1 and related RFCs. Implementations using stdio should not follow it and instead take credentials from the environment. Alternative transports must follow established security practice for their protocol. A public HTTP server holding user data should treat OAuth as mandatory in practice.
Why are RFC 8707 resource indicators important for MCP?
They let a client state which server a token is for, so a supporting authorization server can issue a token whose audience is that server. The MCP server must then verify the audience and reject tokens meant for anything else. This prevents a token stolen from or issued for one service from working at another and underpins the ban on token passthrough. Clients must send the parameter even if the server may ignore it.
What is the lethal trifecta in AI agent security?
Coined by Simon Willison in June 2025, it is the combination of access to private data, exposure to untrusted content and the ability to communicate externally. Together they enable prompt-injected data theft. The practical fix is to ensure no single agent session holds all three: narrow credentials, isolate untrusted input, or enforce an egress allow-list outside the model.
How should I secure a local MCP server?
Treat it as code running with your privileges. Show and approve the exact launch command, install only verified, version-pinned packages, and run it in a container or OS sandbox with a minimal mounted directory and no network unless needed. Prefer stdio, or restrict any local HTTP listener with a token or a Unix domain socket, to block other local processes and DNS rebinding.
Further Reading
Internal:
- MCP architecture explained for hosts, clients, servers and transports.
- Agentic AI security and prompt injection for the model-layer threat landscape.
- Migrating to the stateless MCP specification for what the 2026-07-28 revision changes.
External:
- MCP Specification, Authorization and Security Best Practices, the primary sources for the normative language quoted here.
- Invariant Labs, MCP Security Notification: Tool Poisoning Attacks.
- Simon Willison, The lethal trifecta for AI agents.
- RFC 8707, Resource Indicators for OAuth 2.0 and RFC 9728, OAuth 2.0 Protected Resource Metadata.
By Riju — about
