How to Threat Model an MCP Server (Worked Example)

The Model Context Protocol (MCP) has become the usual way to connect an AI assistant to other software. A SaaS product ships an MCP server, a customer connects it to their assistant, and the assistant can then search, create and send things in that product on the customer's behalf. Your threat modeling method stays the same. What changes is where the trust boundaries sit, and three of them are easy to miss: text your server returns becomes instructions to someone else's model, your server acts on users' behalf with real credentials, and a server someone installs is code they run.

This post threat models one MCP server from start to finish. The example is Invoicely, the small invoicing app from our threat model example, which now offers an MCP server so customers' assistants can work with their invoices. We draw the data flow diagram, run STRIDE against what the MCP specification requires, build the attack tree for the outcome that matters and finish with a scored risk register. The last part covers the other side: the MCP servers your own teams install.

The short version: assume the agent calling your tools has been hijacked, because sometimes it will have been. Tool descriptions and tool results go into the model's context, so draw the context window as a trust boundary and treat any text from users or third parties that your tools return as a possible prompt injection. Then design each tool so a hijacked agent can't do much with it. That means no free-text recipients or URLs on tools that send data out, small read results, narrow OAuth scopes with step-up for writes, and token audience checked on every request. Watch for the combination Simon Willison calls the lethal trifecta: private data, untrusted content and a way to send data out, all available to one agent. A single MCP server can supply all three. Finally, treat each server your teams install as third-party code running with their privileges.

What an MCP server is, in threat model terms

MCP has three parts. The host is the AI application the user works in, such as a chat app, an IDE or an agent. Inside it, a client holds the connection to each server. A server exposes tools (actions the model can call), resources (data the host can read) and prompts (templates). Servers run either as a local process the host starts and talks to over stdio, or as a remote service over Streamable HTTP.

As of 25 October 2026 the current specification revision is 2026-07-28. It made the protocol stateless: there's no initialise handshake and no protocol-level session any more, so a server that needs state across calls passes an explicit handle back and forth as a tool argument. Authorization is optional. A remote server that uses it acts as an OAuth 2.1 resource server, and a local stdio server takes its credentials from the environment instead.

Mapped onto a data flow diagram, that gives:

MCP partOn the DFDWho controls it
Host and clientProcessThe user, or their employer. Not you, if you ship the server.
Remote serverProcess inside your own trust boundaryWhoever runs it
Local stdio serverProcess on the user's machine, running with the user's privilegesWhoever wrote the package, every time it updates
Tool descriptions and resultsData flows into the model's contextThe server, and anyone whose text the server returns
Authorization serverProcessThe server's operator, or their identity provider
The modelExternal entityThe model provider

What has gone wrong so far

Four public incidents show the pattern. All dates are when each was disclosed.

  • Tool poisoning (April 2025). Invariant Labs showed that a tool's description can carry hidden instructions. Their test server offered a harmless-looking tool whose description told the agent to read the user's SSH private key and pass it out in a tool argument. The user saw a normal, shortened summary of the tool. Invariant Labs
  • GitHub MCP server (May 2025). A malicious issue in a public repository told the agent reading it to collect data from the user's private repositories and publish it in a pull request on the public one. Nothing was wrong with the server's code. The agent had a token that could reach both. Invariant Labs
  • mcp-remote, CVE-2025-6514 (July 2025). A widely used helper that connects local clients to remote servers could be made to run operating system commands by a malicious server, through a crafted authorization endpoint URL. It's fixed in version 0.1.16. JFrog
  • postmark-mcp (September 2025). An npm package copying Postmark's email server published 15 clean versions, then added one line in version 1.0.16 that blind-copied every email sent through it to the attacker. Koi Security

None of these needed a clever exploit. Each one crossed a boundary nobody had drawn: text treated as instructions, a token that reached too far, a server trusted to send safe URLs, a package trusted because it had been fine yesterday.

Step 1: Draw the data flow diagram

Invoicely's MCP server is a remote server over Streamable HTTP, behind Invoicely's own authorization server. It offers four tools: list_invoices, get_invoice, create_invoice (which saves a draft) and send_reminder (which emails a payment reminder). For this example Invoicely has also added a feature many invoicing tools have: the client can leave a comment on the invoice page when they view or pay it.

Data flow diagram of Invoicely's MCP server. The customer's AI host contains the customer and the AI assistant (MCP host and client). The assistant sends prompts, tool descriptions and tool results to the LLM at the model provider (flow 2, red), and talks to other MCP servers such as email and web fetch in the same agent (flow 9). It gets a token from Invoicely's authorization server with OAuth 2.1, PKCE and the MCP server's URI as the resource (flow 3), then calls tools on the Invoicely MCP server with that bearer token (flow 4). The MCP server calls the Invoicely API with an internal token for one tenant (flow 5). The API reads and writes the invoices database (flow 6) and sends reminder emails through the email provider (flow 7). The client who pays the invoice can leave a comment on the invoice page (flow 8, red).
The two red flows are where text Invoicely doesn't control reaches a model: a client's comment (8) comes back in tool results, and everything the server returns goes into the model's context (2).

Flow 4 is the one everyone draws. Flows 2 and 8 are the ones that matter. A client types a comment on an invoice. Invoicely stores it and returns it when an assistant calls get_invoice. The customer's assistant then puts that comment into the model's context, next to the user's request and the descriptions of every tool it can call. Flow 9 matters too: Invoicely's tools sit in the same agent as whatever other servers the customer has connected, and Invoicely can't see which ones those are.

Step 2: The three boundaries people miss

A trust boundary is anywhere data or control passes between parties that trust each other differently. An MCP server has three that a quick review usually skips.

1. The context window. Models don't reliably tell data from instructions. Any text your tools return can steer the agent, and so can your tool descriptions. The prompt injection doesn't happen in your server, but your server is how it gets in. If a tool returns text written by someone other than the user, such as comments, ticket bodies, emails, web pages or file contents, mark that flow as untrusted input to the model.

2. The server's credentials. The server acts on users' behalf, so it's a deputy, and confused deputies are the classic failure. The specification is strict here. A server must check that every access token was issued for it (its audience), and must not accept or pass on tokens meant for anything else. Forwarding the client's token straight to a downstream API, called token passthrough, is explicitly forbidden. If your server is a proxy to a third-party API that uses one static OAuth client ID, the specification also requires your server to ask each client for consent before sending the user on to the third party. Without that, a consent cookie the user already has can approve an attacker's client without the user being asked.

3. The install path. A server someone installs is code they run, and whoever publishes the next version decides what that code does. This matters most for servers your teams install, covered at the end.

Together, the first two lead to one design rule: assume the agent calling your tools has been hijacked. Simon Willison's lethal trifecta explains why. An agent with access to private data, exposure to untrusted content and a way to send data out can be made to steal the data. Invoicely's server supplies all three by itself: invoices are the private data, client comments are the untrusted content and send_reminder is the way out.

Step 3: STRIDE against what the spec requires

Run STRIDE over the MCP server and, where the specification has a rule, write it next to the threat. The "MUST" and "SHOULD" wording below quotes the 2026-07-28 revision. A rule the spec puts on clients, such as asking the user before sensitive operations, is something you can't rely on, because you don't control the customer's client.

STRIDEThreat to Invoicely's MCP serverWhat the spec says
SpoofingThe server accepts a token issued for something else, such as Invoicely's web API, or passes the client's token through to the API.Servers MUST check that tokens were issued for them as the audience (RFC 8707) and MUST NOT accept or pass on other tokens.
SpoofingAnother user guesses or obtains a draft invoice handle from create_invoice and uses it.Servers MUST NOT treat holding a state handle as authentication, and SHOULD bind each handle to the user ID taken from the verified token.
TamperingA hijacked agent calls send_reminder or create_invoice with arguments the attacker chose.Servers MUST validate all tool inputs and enforce access control. Clients SHOULD ask the user to confirm sensitive operations, but that's out of Invoicely's hands.
RepudiationNobody can tell which user, through which client, sent a reminder or read an invoice.Clients SHOULD log tool use. Logging on the server side is up to you.
Info disclosureTool results from one customer's account reach another, for example through a cache keyed only by invoice ID.Servers MUST enforce access control. This is the IDOR from the original model in a new form.
Info disclosureOne broad scope grants everything, so a leaked token reads and sends from the whole account.Ask for minimal scopes first and step up for more when needed (scope minimisation guidance).
Denial of serviceAn agent stuck in a loop, or told to, pages through every invoice or sends hundreds of reminders.Servers MUST rate limit tool calls.
ElevationThe server calls the API with one service credential that can reach every customer's account.Not covered by the spec. That's your design (flow 5).

Two rules in the specification are aimed at clients, not servers, and still belong in a threat model of a server. A malicious server can feed a client URLs that point at internal addresses during OAuth discovery (server-side request forgery). It can also hand over authorization URLs that inject scripts or shell commands. That's exactly what CVE-2025-6514 was. If you run an MCP client anywhere, for example an internal agent platform, model those threats against it too.

Step 4: An attack tree for the outcome that matters

The worst realistic outcome isn't "an attacker calls a tool". It's a customer's invoices ending up with someone else. The attack tree follows the trifecta: getting there needs all three branches, so the root is an AND gate. Leaves are scored likelihood × impact on the same 5×5 risk matrix as the original Invoicely model.

Attack tree for exfiltrating a customer's invoices through Invoicely's MCP server. An AND gate requires all three branches. Get instructions into the agent's context is an OR of instructions in a client comment (L4 × I4 = 16 Critical), a poisoned tool from another server (12 High, outside Invoicely's control) and a web page or email the agent reads (16 Critical, outside Invoicely's control). Read the invoices is an OR of list_invoices returning every invoice (16 Critical) and a token scope covering the whole account (12 High). Send them out is an OR of send_reminder accepting any recipient (L4 × I5 = 20 Critical, highlighted) and another tool in the agent sending data out (16 Critical, outside Invoicely's control).
Dashed leaves are outside Invoicely's control. The first branch can't be closed at all, so the work goes into the other two.

Read it from the left. Invoicely can't close the first branch. Prompt injection has no reliable fix, and two of its three leaves live in the customer's agent anyway. That's uncomfortable, and the tree is useful because it makes you say it. The work therefore goes into the branches Invoicely owns. If send_reminder can only ever email the client already on the invoice, Invoicely's server no longer supplies the way out. If list_invoices returns 25 summaries rather than the whole account, whatever does leave through another route is small.

The remaining leaf, another tool in the same agent, belongs to the customer. Invoicely's model records it as an assumption ("customers may connect tools that send data out") rather than pretending it isn't there.

Step 5: Score it and build the register

Here's the risk register for the server, with each threat scored, treated and owned. The IDs carry on from the original model's T1 to T10.

#ThreatL × ITreatmentOwner
M1send_reminder accepts any recipient4 × 5 = 20Mitigate: the recipient is always the client address on the invoice, with no free-text recipient or CC. The email body comes from a template, not model text. A reminders:send scope through step-up, and a daily cap per account.Engineering lead
M2Instructions hidden in client comments4 × 4 = 16Mitigate partly: comments come back in their own field labelled as written by the client, only from get_invoice and never in lists. Accept the residual risk, because no filter reliably stops prompt injection. Review whenever a tool is added.Product lead
M3list_invoices returns the whole account4 × 4 = 16Mitigate: 25 results per page, summary fields only, full detail only from get_invoice, and a rate limit per token.Engineering lead
M4Token audience not checked, or token passed through3 × 5 = 15Mitigate: reject any token whose audience isn't the server's own URI. Exchange it for a short-lived internal token limited to one customer's account. Never forward the client's token.Platform
M5Results from one account reach another3 × 5 = 15Mitigate: tools go through the same authorisation layer that fixed T2. Cache keys include the account. Tests call every tool as another customer.Engineering lead
M6One broad scope for everything3 × 4 = 12Mitigate: publish only invoices:read as the starting scope. invoices:write and reminders:send come through step-up challenges.Platform
M7Tool calls not logged3 × 3 = 9Mitigate: log tool name, arguments, user, OAuth client, account and result count for every call.Engineering lead
M8Guessable draft invoice handles2 × 4 = 8Mitigate: random handles, stored against the user ID from the token and rejected for anyone else.Engineering lead

M2 is the honest row. It's scored, it has an owner, and its treatment says out loud that part of the risk stays. That's what compensating controls are for: M1 and M3 are the reason M2's leftover risk is acceptable. If a future version adds a tool that can send arbitrary email, M2's score has to be reopened, so record that as a trigger in its notes. Changes like that are a standard reason to re-threat model.

If you install MCP servers rather than build them

Most companies install far more MCP servers than they build: developers connect code hosts, ticket trackers, databases and browsers to their assistants. Each of those servers belongs on a diagram as a third party in its own trust boundary, the same way as any vendor integration. The difference is that a local server is code running on an employee's laptop with that employee's access.

ThreatWhere it's happenedTreatment
Impostor or backdoored packagepostmark-mcp, 2025Install from the vendor's own repository or listing. Pin exact versions and read the diff before updating. Prefer the vendor's hosted remote server to a community copy.
A malicious server attacks the clientmcp-remote, CVE-2025-6514Keep clients and connection helpers patched like any other software. Connect only to servers you'd trust to hand you URLs.
Tool descriptions change after approvalShown in tool poisoning researchA server can change its tool list at any time. Pin versions, and treat a changed description like a code change that needs review.
Local server runs with the user's privilegesThe spec's own example: a startup command that sends ~/.ssh/id_rsa to a remote hostRun local servers in a sandbox or container with limited file and network access. Use stdio rather than an HTTP port on localhost.
Token reaches more than the task needsGitHub MCP, 2025Fine-grained tokens limited to the repositories or projects the task needs. Separate agents or profiles for public and private work.
A toxic combination of serversThe trifecta, spread across several serversDon't give one agent a server that reads private data, one that reads untrusted content and one that can send data out, unless every send needs the user's approval.

This is a supply chain problem with a shorter fuse. A malicious npm library has to wait until your code calls it. An MCP server runs as soon as someone connects it, and the model decides when to call its tools.

What threat modeling can't do here

  • It can't make a model immune to prompt injection. No system prompt, filter or delimiter reliably stops it as of October 2026. The model treats injection as a given and limits what a hijacked agent can do.
  • It can't review community servers' code for you. It records which servers you've decided to trust, why, and who owns that decision.
  • It can't control your customers' agents. If you ship a server, you can't see what else your customers connect, or whether their client asks before sensitive calls. Write those down as assumptions so nobody mistakes them for controls.
  • It can't keep up with the specification by itself. The authorization rules have changed in every revision since March 2025, and the 2026-07-28 revision dropped protocol sessions entirely. Re-check the model when you move to a new revision.

A checklist for your MCP server

  1. Draw the host, client, server, authorization server and every API the server calls, with the model as an external entity.
  2. Mark every tool result that can contain text written by someone other than the user as untrusted input to the model.
  3. Check whether your server alone supplies private data, untrusted content and a way to send data out. If it does, break one of the three.
  4. Remove free-text recipients, URLs and destinations from any tool that sends data out of your system.
  5. Check every token's audience. Never pass a client's token downstream, and use an internal token limited to one account.
  6. Start with a read-only scope and require step-up for writes and sends.
  7. Cap result sizes and rate limit every tool.
  8. Bind state handles to the user from the verified token.
  9. Log every tool call with user, client, account and arguments.
  10. Write down what you assume about the customer's agent, and say plainly which risks you've accepted.

Sources, as of 25 October 2026: the MCP specification's security best practices, authorization and tools pages for revision 2026-07-28, plus the incident write-ups linked above.

Frequently asked questions

Is an MCP server just an API?

On the network side, yes, and everything you'd do for an API still applies: authentication, authorisation, input validation and rate limits. The difference is who calls it. The caller is a model acting on instructions that may not be the user's, and what your server returns goes back into that model's context. So a threat model of an MCP server adds two questions an API review usually skips: what could someone else's text in my results make the agent do, and what can a hijacked agent do with my tools?

What are the main security risks of MCP servers?

For a server you build: prompt injection carried in tool results, tools a hijacked agent can use to send data out, tokens not checked for audience or passed through to other APIs, results leaking between customers, and scopes that are too broad. For servers you install: impostor or backdoored packages, servers that change their tools after you approve them, local servers running with your privileges, and tokens that reach more than the task needs.

How do I stop prompt injection through MCP tools?

You can't stop it reliably, so design for it. Return text written by other people in clearly labelled fields, and only from tools that need it. Make sure the tools that send data out can't be pointed at an address the attacker chooses. Keep read results small. The goal is that a hijacked agent can't do much damage, not that the agent never gets hijacked.

Should every MCP tool require human approval?

The specification says there should always be a human able to deny tool calls, and that clients should ask for confirmation on sensitive operations. In practice, approval prompts on every read train people to click "allow". Put approval on actions that send data out, change money or delete things, and make read tools safe enough to run without it. If you ship the server, remember the approval prompt is the client's job, so your own limits have to hold without it.

How do I check a third-party MCP server before installing it?

Confirm the publisher is the vendor it claims to be, and install from the vendor's own repository or listing. Read what the tool descriptions say and what credentials the server asks for. Pin the version and review changes before updating. Run local servers in a sandbox. Then add the server to your data flow diagram as a third party with an owner, so the next review knows it's there.

Threat model your MCP server in ThreatTree

Draw the host, your server and every API it calls as a data flow diagram, with a trust boundary around each. Build the attack tree for what a hijacked agent could do, score each leaf and give every treatment an owner in the risk register, free. With the AI & ML Systems module on Pro, the palette adds an MCP Server element with its own suggested threats, tagged to OWASP LLM and Agentic items and mapped to NIST AI RMF, plus a Context Window trust boundary. Or open a finished example model first and see how it fits together.

Or start a blank model →

Not ready to sign up? Get new threat-modeling guides by email instead.