MCP security is the work of keeping a Model Context Protocol setup from doing things its user never asked for. The protocol connects a language model to tools, and the model decides which tool to call based on text. Some of that text comes from people you have never met. That's the root of almost every MCP attack so far: a model that can read untrusted words and take real actions will, sooner or later, be talked into the wrong action.
This guide covers the risks behind real exploits and CVEs, what the current spec revision (2026-07-28) asks servers to do, and a checklist for server builders. We build DialMCP, a hosted MCP server whose main tool places real phone calls, so we use it as the worked example at the end, weak spots included.
Why MCP is hard to secure
A normal API is called by code a developer reviewed. An MCP tool is called by a model that picked it at runtime after reading tool descriptions, the user's request and earlier tool results. Any of those can carry instructions, and the model can't reliably tell yours from an attacker's.
Simon Willison named the dangerous combination in June 2025 as the lethal trifecta: access to private data, exposure to untrusted content, and a way to communicate externally. In his words, "If your agent combines these three features, an attacker can easily trick it into accessing your private data and sending it to that attacker." He also pointed at MCP directly, because it "encourages users to mix and match tools from different sources that can do different things."
The spec's overview admits that "MCP itself cannot enforce these security principles at the protocol level." So the useful question for a server author is not "is MCP secure?" but "what can my server guarantee no matter what the model says?"
The risks, at a glance
| Risk | What goes wrong | Main defense |
|---|---|---|
| Tool poisoning | Hidden instructions in a tool description steer the model | Review and pin tool definitions; hosts show full arguments |
| Rug pull and shadowing | A server changes its tools after approval, or one server's description hijacks another server's tool | Hash the whole definition; prefix tool names per server |
| Prompt injection via tool output | Text a tool returns (an issue, an email, a web page) carries instructions | Treat results as data; limit what the agent can reach |
| Token passthrough | The server accepts or forwards tokens that were not issued to it | Validate the audience; never forward the client's token |
| Confused deputy | A proxy's static OAuth client lets an attacker skip consent | Per-client consent on the server |
| State handle hijacking | Someone guesses or steals a cart or job ID and uses another user's state | Bind handles to the user from the verified token |
| Local server exposure | A web page reaches a localhost server through DNS rebinding | Check Origin, bind to 127.0.0.1, require auth |
| Classic bugs | Command injection, path traversal and SSRF inside server code | Ordinary input validation and dependency updates |
Tool poisoning, rug pulls and shadowing
Invariant Labs published one of the earliest widely cited MCP attacks on April 1, 2025, under the title "MCP Security Notification: Tool Poisoning Attacks". They defined it as "malicious instructions are embedded within MCP tool descriptions that are invisible to users but visible to AI models." Their demo was a harmless-looking add tool. Its description told the model to read ~/.cursor/mcp.json and the user's SSH private key, then pass them in an extra argument. In Cursor, the model did it. The confirmation dialog didn't show the full arguments, so the key went out unseen.
The same post described two variants. A rug pull is when "a malicious server can change the tool description after the client has already approved it." Shadowing is subtler: one server's description rewrites how the model uses a different, trusted server's tool. In their example, a poisoned description made a trusted send_email tool send every message to the attacker. The poisoned tool never had to be called.
Hosts can show the full tool input and pin tool definitions so a change triggers a fresh review. The OWASP MCP Security Cheat Sheet adds a detail that's easy to miss. Hash the whole definition, not only the description, because "a flipped readOnlyHint or destructiveHint can change client approval behavior without any visible description change." Server authors should keep descriptions plain and never change behavior under an unchanged name.
Prompt injection through tool results
Tool poisoning needs a malicious server. Prompt injection through tool output doesn't. In May 2025, Invariant showed that the official GitHub MCP server could be turned against its own user with nothing more than a crafted issue in a public repository. An agent running Claude 4 Opus in Claude Desktop was asked to look at open issues. It read the planted instructions, then pulled data from the user's private repositories into a pull request on the public one. Invariant called this a "toxic agent flow" and was clear that it is "not a flaw in the GitHub MCP server code itself."
Every tool that returns text written by someone else is a channel: issues, emails, tickets, web pages. In our case it's a phone transcript, since whoever answers can say anything. There's no filter that reliably separates data from instructions, and Willison's warning applies: "in web application security 95% is very much a failing grade."
What a server can do is shrink the damage. Return structured fields where you can, so the model gets "status": "shipped" rather than a stranger's paragraph. Keep tools narrow, so a hijacked model can only do narrow things. And enforce every rule that matters in code, outside the model, so a persuaded model still hits a wall.
Tokens, consent and the confused deputy
Remote MCP servers use OAuth, and most of the spec's own security guidance is about getting OAuth right. Two rules matter most for server authors. On token passthrough, the 2026-07-28 best-practices page is blunt: "MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server." Check the audience, and if your server calls another API for the user, get a separate upstream token. This bug class is still turning up. On October 20, 2026, GitHub published an advisory for the official TypeScript SDK titled "Server bearer authentication accepted tokens issued for another service" (GHSA-rvq5-wwqv-78pq). Version 1.32.0 adds an audience check, but it's off by default: you pass expectedResource and return the token's audience from your verifier, or you check aud yourself.
The second rule is about servers that act as OAuth proxies to a third party with one static client ID. The spec calls the risk a confused deputy: an attacker's freshly registered client can ride a user's earlier consent cookie and receive an authorization code without the user approving it. The fix is that proxy servers "MUST implement per-client consent." That means a consent screen that names the client, shows the scopes and the redirect URI, resists CSRF and refuses to load in an iframe. This isn't theoretical either. FastMCP's OAuth proxy had a missing-consent bug of this kind (CVE-2026-27124, fixed in 3.2.0).
Keep scopes small up front and step up when a privileged tool is first used. The full OAuth flow is in MCP authorization and OAuth, and MCP gateways covers where passthrough creeps in behind a gateway.
State handles: a 2026-07-28 change
The 2026-07-28 revision removed protocol sessions. There's no initialize handshake and no Mcp-Session-Id header any more. A server that needs state across calls mints its own handle, such as a cart ID or a job ID, and the model passes it back as an ordinary tool argument. So the best-practices page replaced its "Session Hijacking" section with State Handle Hijacking.
The rule is simple: "MCP servers MUST NOT treat possession of a state handle as authentication." Generate handles with a secure random generator, store them keyed by the user ID from the verified token, and reject a handle presented by anyone else. The tools page puts it well: for authenticated servers, "a handle is a name, not a capability." The session-era version of this bug was real: on servers using its bearer auth, the Python SDK routed messages by session ID without checking the sender until 1.27.2 (CVE-2026-52869).
Local servers, DNS rebinding and plain old bugs
A server on your laptop feels private, but with DNS rebinding a web page you visit can make your browser send it requests. The Streamable HTTP spec has three rules for this. Servers MUST validate the Origin header and return 403 when it's invalid. Local servers SHOULD bind to 127.0.0.1 rather than 0.0.0.0. And servers SHOULD authenticate all connections.
The official SDKs only protect you by default on the easy path. The TypeScript, Python and Go SDKs all shipped fixes after their DNS-rebinding defaults were reported as CVEs (CVE-2025-66414, CVE-2025-66416 and CVE-2026-34742). Today, each turns protection on automatically only when you use the high-level helper and bind to localhost. Bind to 0.0.0.0, or wire up the low-level transport yourself, and you have to configure allowed hosts and origins on your own.
Plenty of MCP CVEs are ordinary bugs in new places. mcp-remote allowed OS command injection through a malicious server's authorization URL (CVE-2025-6514, CVSS 9.6, fixed in 0.1.16). MCP Inspector's proxy let unauthenticated requests launch commands (CVE-2025-49596, fixed in 0.14.1). The reference filesystem and git servers had path traversal bugs, and a FastMCP SSRF scored 10.0 (CVE-2026-32871). The cure is ordinary: input validation and current dependencies.
Why annotations and confirmations aren't enough
The spec says "there SHOULD always be a human in the loop with the ability to deny tool invocations," and hosts use annotations to decide when to ask. Neither is a server-side control. Annotations are hints. The spec says clients "MUST consider tool annotations to be untrusted unless they come from trusted servers," because a malicious server can label a destructive tool read-only. When a tool sets no annotations at all, the defaults treat it as a destructive write that reaches the outside world (see MCP tools explained for all four hints). Confirmation prompts have their own problem. Invariant noted that "many users already opt for an 'Always Allow' confirmation policy when using agents, and stop monitoring individual actions." A server whose tools cause real-world effects can't assume a human read the prompt.
The spec's server-side list is short: servers MUST "Validate all tool inputs," "Implement proper access controls," "Rate limit tool invocations," and "Sanitize tool outputs." For tools that spend money, send messages or call people, that's the minimum.
An MCP server security checklist
Every server
- Accept only your own tokens. Validate the audience on every request. Never forward a client token upstream.
- Take identity from the token, not the arguments. If a tool acts "as the user," the user ID comes from the verified token. A model should never be able to pass a different account in an argument.
- Bind every handle to its owner. Random IDs, stored per user, and an error that doesn't confirm whether another user's handle exists.
- Per-client consent if you proxy a third-party OAuth provider. Name the client, show the redirect URI, protect against CSRF and framing.
- Minimal scopes up front, with step-up for privileged tools.
- Strict input schemas. Patterns, enums, length limits and
additionalProperties: false. Validate again in code. - Structured output where you can, and clear marking of any field that contains third-party text.
- Honest annotations. Set
readOnlyHintanddestructiveHintto match what the tool really does, and keep definitions stable. - Origin checks and localhost binding for any local HTTP server, even if your SDK claims to do it.
- Patch the SDK. The 2026 advisory list for the official SDKs is long. Pin versions and watch GitHub's advisory feed.
Tools that act in the real world
- Hard limits in code: rate, concurrency, spend and per-target caps that no prompt can change.
- Deny-lists that can't be bypassed for targets that must never be hit, checked before anything else.
- A kill switch that stops all actions without a deploy.
- Narrow action surfaces. A tool that books a table shouldn't also be able to wire money.
- Deterministic checks first, AI checks last. A model-based screen is useful, but it's the soft layer. Nothing that must hold should depend on it alone.
- Refusals the model can act on. A machine-readable error code and a reset time stop the model from retrying blindly.
- An audit trail of who did what, when, and why it was allowed.
- A way for third parties to opt out, honored platform-wide.
How DialMCP applies this
DialMCP is a remote MCP server whose place_call tool makes an AI voice agent phone a business for you, from your own verified number. A tricked model could try to call someone it shouldn't, so the rules live on the server, where the model can't argue with them. Everything below comes from our own code and matches what we publish on our safety page.
- Our own authorization server. DialMCP issues the tokens that
/mcpaccepts, after SMS verification and a consent screen that names the client. There's no upstream user token to pass through. Phone-carrier and model credentials stay on the server. - Caller ID from the token. The number a call comes from is the verified number in the grant. It isn't a tool argument, so no prompt can change it.
- Call IDs bound to the account.
get_callandend_calllook calls up by user and ID together. Someone else's call ID returns "Unknown call_id for this account." - A fixed gate before every dial. First come an operator kill switch and a blocklist for emergency and premium-rate numbers, which no configuration can skip. Then, in order: the platform-wide opt-out list, one call at a time, 3 calls an hour and 10 a day, 2 calls a day to the same number, calling hours of 8:00 to 21:00 in the destination's time zone, and a line-type lookup. Only then does a separate model screen the objective for harassment or deception. Operators can lift the later checks for internal test numbers; the first two never lift. Rejections come back as JSON with an error code, and the hourly, daily and per-number limits include a reset time.
- A narrow agent on the call. During the call, the voice agent's only tools are pressing keys, waiting on hold and hanging up. It is instructed to say that it's an AI and that the call is recorded before it asks for anything, and every transcript is checked for that disclosure afterward. A person who answers can say anything, but they can't make it do much.
- Opt-outs that stick. A spoken "don't call again" is written to the suppression list first, and a second pass over the transcript catches any the agent missed.
Where it's still weak, honestly: our tools set no annotations yet, so under the spec's defaults even get_call counts as a destructive write that reaches the outside world. And get_call returns the call transcript, which is text spoken by a third party, without an explicit "untrusted" label. We return a separate structured resolution, extracted by a model call with no tools, and our tool description tells the model to read that first. Still, a host model reading the raw transcript is reading a stranger's words, and it should treat them as data.
Where to read more
Start with the spec's Security Best Practices for 2026-07-28; some search results still show the 2025 version, which predates state handles. The OWASP MCP Top 10 (still in beta) is a good taxonomy, and CoSAI's MCP Security paper, updated August 12, 2026, lists 34 threats in 12 categories. If you're still choosing how to run your server, read remote vs local MCP servers first.
Want to see a server-enforced gate from the model's side? DialMCP is a hosted remote MCP server that lets Claude, ChatGPT, Codex or Cursor place a real phone call from your own verified number, with disclosure, limits and opt-outs enforced on the server. Connect to https://mcp.dialmcp.com/mcp and sign in with OAuth. US and Canada, free during launch.
FAQ
Is MCP secure?
MCP is a protocol, and its spec says it cannot enforce security principles at the protocol level. Security depends on the host and the servers. The main risks are tool poisoning, prompt injection through tool results, token mistakes and ordinary bugs in server code. A well-built server enforces its own rules in code, so a tricked model still cannot break them.
What is MCP tool poisoning?
Tool poisoning is hiding instructions in an MCP tool's description, which the model reads but the user usually does not see. Invariant Labs disclosed it on April 1, 2025, with a demo that made Cursor send the user's SSH key to a malicious server. Variants include rug pulls, where a tool changes after approval, and shadowing, where one server's description hijacks another server's tool.
What are the MCP security best practices for servers?
Accept only tokens issued to your server and never forward them. Take user identity from the verified token, not tool arguments. Bind state handles to the user. Validate inputs strictly, rate limit tools, and return structured output. For local HTTP servers, check the Origin header and bind to 127.0.0.1. For tools with real-world effects, add hard limits, a deny-list and a kill switch in code.
What changed for MCP security in the 2026-07-28 spec?
MCP became stateless, so session IDs are gone and servers mint their own state handles. The security best practices replace session hijacking with state handle hijacking and add sections on mix-up attacks, localhost redirect impersonation and Client ID Metadata Document trust policies. Clients must also validate the iss parameter when an authorization server sends it.
Are MCP tool annotations a security control?
No. Annotations such as readOnlyHint and destructiveHint are hints that help a host decide when to ask the user for confirmation. The spec says clients must treat them as untrusted unless they come from a trusted server. A server still has to enforce its own access checks and limits.