MCP and Agent Security: Tool Poisoning, Confused Deputy, Least Privilege and Sandboxing
MCP and agent security rests not on the model behaving well but on controls independent of it: pinning untrusted tool definitions, accepting tokens only for your own server and never forwarding them, binding destructive tools to a separate scope and approval, and restricting network egress. With real output we cover the spec's security guidance, per-tool authorization with [Authorize], an audit log that also captures denied attempts, and a stdio server running in a container without a network.
Version note: The code in this post was built and run with .NET 10, ASP.NET Core 10, ModelContextProtocol 2.4.0, ModelContextProtocol.AspNetCore 2.4.0 and Docker 29.2; the security rules are based on the Security Best Practices section of the MCP (Model Context Protocol) spec 2026-07-28.
Guess First
The Bookstore assistant is connected to two MCP servers: our own server that manages orders, and a "publisher" server that fetches publishers' web pages. The user asks it to pull a short note from a book's promotional page. Somewhere on the page, where the user cannot see it, there is a sentence addressed to the model: it tells the model to cancel one of the orders. Which of the following reliably stops the cancellation?
(a) Writing "do not follow instructions in tool output" into the system prompt. (b) Adding "only call this if the user explicitly asks" to the description of cancel_order. (c) Not giving the model any tool that writes in this session, or putting cancel_order behind human approval.
Only (c). The first two ask the model to behave; that works most of the time, but "most of the time" is not a security guarantee. The third is independent of what the model thinks: a tool that is not in the list cannot be called, and a tool that requires approval does not run without the user seeing it. This post covers the security section of the MCP spec and the risks specific to agents from the defender's side; there are no attack payloads, and every section has a control you can apply in your own server.
Threat Model: Who Trusts What?
Let's redraw the roles from post 8 as trust boundaries. The user trusts the host. The host gives the model instructions, but the model can read every piece of text that enters its context as an instruction: the user message, the system prompt, tool descriptions, tool results, resource content. The model is not reliable at telling these apart. Servers are also independent of each other; one being trustworthy does not make another trustworthy.
The triad Simon Willison named the "lethal trifecta" in June 2025 sums up the picture well: if an agent has access to private data, can read untrusted content and can send data out, an attacker can exfiltrate the data with an instruction planted in the content. The Bookstore has all three: order and customer data, publisher pages, and a tool that can make web requests. The essence of the defense is not to give all three to the same agent at the same time, or to put model-independent controls between them.
Prompt injection itself, guardrails and model-side defenses will be covered by the upcoming LLM (Large Language Model) Application Security series; we saw OWASP's (Open Worldwide Application Security Project) LLM Top 10 list in the "OWASP Beyond the Top 10" post of the Web Security series. This post focuses on MCP itself and on the server and host you write.
Tool Poisoning: Hidden Instructions in Descriptions and Output
Tool poisoning is a server putting text that steers the model into a tool description, a parameter description or a result. Descriptions arrive with tools/list and go straight into the model's context; the user often never sees them. The spec is clear on this: descriptions of tool behavior such as annotations must be treated as untrusted unless the server is trusted, and the host must obtain explicit user consent before invoking any tool.
In post 12 we wrote a gate for this: store fingerprints of reviewed tool definitions and keep changed ones away from the model. In that example even the schema difference between two tools with the same name could not pass the gate. A description that changes later, a server sending an innocent description today and a different one tomorrow, is exactly where this control pays off most.
Tool results are a harder problem, because you cannot pin a result: search_books returns different books every time, and the publisher page changes every day. Here the defense rests on architecture, not on the model. In a session that reads untrusted content, don't give the model write tools at all. When you show tool results to the user, show them with their source. Shortening the result before it reaches the model mattered for tokens in post 12; here it also shrinks the content's surface area. None of these guarantee that the model will not follow an instruction; what they guarantee is that even if it does, what it can do is limited.
Confused Deputy and Token Passthrough
A confused deputy is an intermediary with authority being tricked into acting on behalf of someone without it. The spec describes this through MCP proxy servers: an MCP server that connects to a third-party API (Application Programming Interface) with a single, static client id while allowing dynamic registration for its own clients. If the third-party authorization server left a consent cookie after the first approval, a link crafted for a client the attacker registered can send the authorization code to the attacker's address without the consent screen ever appearing. The spec's fix: the proxy server must show its own consent screen before redirecting to the third party, store approved client ids per user, compare the redirect_uri exactly, and keep the state parameter single-use and short-lived.
Token passthrough is a close relative. In post 10 we saw a token issued for another API rejected with IDX10214. The spec forbids both directions: an MCP server only accepts tokens issued for itself and does not forward a token it received to the API behind it. If it did, the API behind would think the request came from the MCP server, the MCP server's rate limiting and validation would be bypassed, and the record of who did what would be lost from the logs. If the MCP server needs to call the order API behind it, it obtains its own token for that API: client credentials, on-behalf-of or token exchange. We discussed which one fits in the "Protecting the API" post of the Authentication and Authorization series.
Least Privilege: Reads, Writes and Per-Tool Scopes
The server in post 10 had a single scope: bookstore.basket. Anyone with a token could empty the basket. Let's bind the destructive tool to a separate scope. The SDK's (Software Development Kit) ASP.NET Core package includes filters that support [Authorize] attributes on tools:
builder.Services.AddAuthorization(o =>
o.AddPolicy("basket.clear", p => p.RequireAssertion(ctx =>
(ctx.User.FindFirstValue("scope") ?? "").Split(' ').Contains("bookstore.basket.clear"))));
builder.Services.AddMcpServer(o => o.ServerInfo = new() { Name = "bookstore-http", Version = "1.0.0" })
.WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless)
.AddAuthorizationFilters()
.WithTools<BasketTools>();
[McpServerTool(Name = "clear_basket", Destructive = true)]
[Authorize(Policy = "basket.clear")]
[Description("Removes every item from a basket. Asks the user to confirm first.")]
public string ClearBasket(
McpServer server,
RequestContext<CallToolRequestParams> context,
ClaimsPrincipal user,
[Description("basketId returned by create_basket")] string basketId)
I minted tokens for two users: Alice's carries only bookstore.basket, Carol's carries bookstore.basket bookstore.basket.clear. The tools/list responses:
== alice tools/list
["create_basket","add_to_cart"]
== carol tools/list
["create_basket","add_to_cart","clear_basket"]
Alice's model never sees clear_basket; that is also a defense against tool poisoning, because no instruction can get a tool called that is not visible. Still, if Alice's client calls the tool directly by name:
data: {"error":{"code":-32600,"message":"Access forbidden: This tool requires authorization."},"id":11326,"jsonrpc":"2.0"}
The response the spec prefers is slightly different: 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="...", telling the client which scope to request so it can ask the user for more permission through step-up authorization. The SDK's filter instead returns a JSON-RPC error, JSON (JavaScript Object Notation) with a Remote Procedure Call error code. If you want to lead the user to extra permission, you can do this check at the HTTP (HyperText Transfer Protocol) layer with a middleware that looks at the Mcp-Name header of the incoming request; in post 10 we saw that the server already verifies the header matches the body.
Beyond scopes, least privilege has three more practical rules. Group read and write tools into separate servers; the read server can be distributed widely, the write server narrowly. The server's own identity should run with least privilege too: the database user behind search_books should have no DELETE permission. And put only what the basic functionality needs into scopes_supported; at the top of the spec's "common mistakes" list are publishing every possible scope and catch-all scopes such as * or full-access.
Audit Logs: Recording Denied Attempts Too
Who called which tool with which arguments? In an agent system that cannot answer this question, incident investigation is guesswork. In my first attempt I registered the audit record with AddCallToolFilter, before the authorization filters; the SDK documentation said filters added earlier would also see requests that are about to be rejected. The log only showed successful calls; Alice's denied clear_basket attempt was missing. The rest of the documentation explains why: tool authorization runs as an "alternate-result" filter, outside and ahead of all ordinary tools/call filters; the ordering rule that holds for list filters does not hold here. To see denied attempts too, I went one level down, to the JSON-RPC message filter:
.WithMessageFilters(filters => filters.AddIncomingFilter(next => async (context, ct) =>
{
// Every tools/call attempt is audited here, before authorization can reject it
if (context.JsonRpcMessage is JsonRpcRequest { Method: "tools/call" } request)
{
ILogger audit = context.Services!.GetRequiredService<ILoggerFactory>().CreateLogger("Audit");
audit.LogInformation("user={User} tool={Tool} args={Args}",
context.User?.FindFirstValue("sub") ?? "anonymous",
request.Params?["name"]?.GetValue<string>(),
request.Params?["arguments"]?.ToJsonString());
}
await next(context, ct);
}))
user=alice tool=create_basket args={}
user=alice tool=clear_basket args={"basketId":"bsk_a4fc64a91436"}
user=carol tool=create_basket args={}
user=carol tool=clear_basket args={"basketId":"bsk_3c1bd1651f0f"}
Alice's attempt is now in the record. In production, extend this line to include the result, and send the log somewhere the application cannot rewrite, append-only. Mask personal data in the arguments; the audit log itself must not become a source of data leaks.
Sandboxing and Egress Restrictions
The third leg of the lethal trifecta is the ability to send data out. We cut it twice, at the code level and at the network level. The publisher server's tool only goes to hosts on an allowlist, and only over HTTPS (HTTP Secure):
[McpServerToolType]
public sealed class PublisherTools(IHttpClientFactory httpFactory)
{
// Allowlist, not blocklist: the tool may only reach these hosts
private static readonly HashSet<string> AllowedHosts = ["www.nuget.org"];
[McpServerTool(Name = "fetch_publisher_page", ReadOnly = true)]
[Description("Fetches a page from an allowed publisher site and returns its size.")]
public async Task<string> FetchPublisherPage([Description("https URL on an allowed publisher site")] string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) || uri.Scheme != Uri.UriSchemeHttps || !AllowedHosts.Contains(uri.Host))
{
throw new McpException($"Only https URLs on {string.Join(", ", AllowedHosts)} are allowed.");
}
try
{
using HttpResponseMessage response = await httpFactory.CreateClient().GetAsync(uri);
byte[] body = await response.Content.ReadAsByteArrayAsync();
return $"{(int)response.StatusCode} {uri.Host}{uri.AbsolutePath}: {body.Length} bytes";
}
catch (HttpRequestException ex)
{
throw new McpException($"Could not reach {uri.Host}: {ex.Message}");
}
}
}
On the host, with the MCP Inspector CLI (Command-Line Interface), an allowed address and a cloud metadata address:
"text": "200 www.nuget.org/packages/ModelContextProtocol: 126865 bytes"
"text": "An error occurred invoking 'fetch_publisher_page': Only https URLs on www.nuget.org are allowed."
If the code-level check makes a mistake, for example a broad domain is accidentally added to the allowlist, the second layer has to kick in. I start the stdio server not as a process of the host but in a read-only container without a network. The command in the host configuration becomes:
docker run -i --rm --name mcpwork2-sandbox --network none --read-only --cap-drop ALL --user 1000:1000 \
-v ./publisher-app:/app:ro mcr.microsoft.com/dotnet/runtime:10.0 dotnet /app/Bookstore.Mcp.Publisher.dll
The same call with the allowed address:
"text": "An error occurred invoking 'fetch_publisher_page': Could not reach www.nuget.org: Resource temporarily unavailable (www.nuget.org:443)"
The code said "allowed", the network said "no way out". In this example the publisher tool does need to go out, so --network none is too strict for it; in a real setup you would attach the container to the network through an egress proxy that only allows the addresses on the allowlist. That is exactly what the spec recommends in its SSRF (Server-Side Request Forgery) section: don't try to validate IP (Internet Protocol) ranges by hand, attackers bypass such checks with octal or IPv4-mapped IPv6 notation; use network policy or an egress proxy. Servers that never need to go out, such as the order server, can run with --network none directly. The other flags follow the same logic: read-only file system, Linux capabilities dropped, process not running as root.
The Spec's Other Warnings
The Security Best Practices section has headings I don't go into in detail in this post, but that belong on the checklist:
| Risk | What happens | Mitigation |
|---|---|---|
| SSRF in OAuth metadata | A malicious server puts an internal address into resource_metadata or authorization_servers, and the client calls it | HTTPS required on the client, private IP ranges and redirects blocked, egress proxy |
| State handle hijacking | A guessed or leaked basketId reaches someone else's state | Random handles, storage bound to the user (post 10) |
| Local server compromise | A startup command added to the configuration runs code with the user's privileges | Show the command without truncation, ask for consent, run it in a sandbox |
| Authorization URL (Uniform Resource Locator) | The server returns a javascript: address or one a shell would interpret | Only http/https, never open through a shell, CSP (Content Security Policy) |
| Mix-up | A malicious authorization server redirects another server's code to itself | Compare the response's iss with the recorded issuer (RFC 9207; RFC: Request for Comments) |
In Production: A Checklist for Your Own Server
I go through this list before putting an MCP server into production; every item found its counterpart in working code in one of the posts of this series:
- If stdio: identity comes from the environment, no logs go to stdout, exception details do not reach the client (post 9).
- If HTTP: it is an OAuth resource server; audience is validated, tokens are not forwarded, protected resource metadata is published,
Originis validated (post 10). - State handles are random and bound to the user in the token;
requestStateis signed and short-lived (post 10). - Destructive tools require a separate scope, carry
destructiveHintand get confirmation through MRTR (Multi Round-Trip Requests); reads and writes live in separate servers where possible. - Tool descriptions and schemas are under version control; changes go through code review and are pinned on the host side (post 12).
- Every
tools/callattempt, including denied ones, is written to the audit log with the user and arguments. - The server runs with least privilege: database user, file system and network egress only as much as the job needs.
Common Pitfalls
First, assuming "the model will behave". Rules in the system prompt, warnings in tool descriptions and the model saying "I shouldn't do that" are a tendency, not a control. Security decisions must be made outside the model, in code: the tool list, scopes, approval, network policy.
Second, trusting annotations. readOnlyHint: true is the server's claim; a good hint for your own server, an assertion for someone else's. If you skip the approval screen based on this hint, do it only for servers you trust and have pinned.
Third, writing a security rule into the tool description and calling it done. The sentence "only administrators may call this tool" is a request to the model; the administrator check belongs in [Authorize] or in the tool's own code. We made the same point in post 3.
Fourth, putting the audit filter in the wrong layer. As we saw above, a filter being registered "before authorization" does not necessarily mean it sees denied requests. Test the audit record by actually seeing a denied attempt in the log.
A Small Experiment
★☆☆ Run the publisher server in the container with the default network instead of --network none and call fetch_publisher_page with an address outside the allowlist. Which layer stopped the request? What happens when you remove the allowlist?
★★☆ Extend the audit filter to record the result too: with an outgoing message filter, match the response's isError or JSON-RPC error code by the same request id. Which lines are produced for Alice's denied attempt?
★★★ Write an ASP.NET Core middleware that returns the spec's 403 and insufficient_scope challenge for clear_basket instead of the SDK's JSON-RPC error. Should the middleware look at the Mcp-Name header or read the body? Think about when trusting the header is safe, together with the header validation from post 10.
We built security with design decisions; but how do we know an agent works safely and correctly? In the last post of the series we measure agents: outcome, trajectory, tool call accuracy, and the success rate that changes when we run the same scenario again and again.