Skip to content
MCP13 min read

Hiding a Tool From an AI Agent Doesn't Secure It: Notes on Zero-Trust MCP

A lot of MCP servers are built on an assumption nobody writes down: if the agent is allowed to connect, it can see every tool, and each tool's code will refuse anything the caller shouldn't do. That holds up until the agent reads a document with instructions hidden in it.

A paper from three engineers at PayPal, Zero-Trust Authorization and Discovery for Enterprise MCP (Huan Li, Yuwei Wang and Srinivasan Manoharan, September 2026), puts numbers on what happens next. They ran 2,160 prompt-injection attempts across four frontier models against an MCP server with one forbidden tool. The main lesson for anyone running MCP servers that do real work is that taking a tool out of the agent's list is not enough on its own, even though it stopped every attempt in their test.

This post walks through what the paper found, what the authors built, where the evidence is thin, and what I would change on an MCP server today.

The caller can be talked into things

A normal API client does what its code says. An agent does what its context says, and its context can include retrieved documents, tool results, web pages and user input, any of which an attacker can influence. Authenticating the request tells you whose credential it carries. It tells you nothing about whether a poisoned document chose the action.

The paper's position is that authorization at the MCP server has to give the same answer whether the agent is behaving normally, hallucinating or has been prompt-injected. The agent's instructions should never be able to widen what its credential allows. Defenses inside the model make it more likely to refuse a malicious instruction. Server-side authorization means it doesn't matter whether it refuses.

The authors map this onto NIST SP 800-207 and derive four requirements for MCP:

  1. Every tool call carries verified credentials, not just the first connection.
  2. An agent sees and can call only the tools it is authorized for, so an injected prompt cannot add tools to its vocabulary.
  3. A registry can discover a server's tools and auth methods without holding credentials.
  4. Several identity providers can coexist on one server.

What the MCP SDKs don't give you

The authors surveyed six official MCP SDKs at their latest stable releases as of 15 May 2026: Python, TypeScript, Go, Rust, C# and Swift. None met all four requirements through documented extension points.

CapabilityPython + FastMCPTypeScriptGoRustC#Swift
Pluggable auth backendPartialPartialPartialYesYesYes
Pre-auth discovery (schemas and auth methods)NoNoNoNoNoNo
Tool list filtered by permissionYesNoNoNoNoNo
Token verification for vendors without RFC 7662 introspectionNoNoNoNoNoNo
Declarative per-tool permissionsYesNoNoNoNoNo

"Partial" means verification is pluggable but credential extraction is tied to the Authorization header.

That header binding is the gap that hurts most in practice. A typical enterprise MCP server has two kinds of caller: people signing in through corporate SSO with a Bearer token, and automation using a service-account credential in a different header. With the Python, TypeScript and Go SDKs, serving both from one server means replacing SDK middleware rather than configuring it. The alternative, running a separate server per credential type, splits the tool catalog in two and doubles what you have to operate.

The paper is fair about FastMCP. Since version 3.0, FastMCP's component-level auth= parameter already hides a tool from listings and blocks direct calls to it, so the authors do not claim novelty there. What they add is covered below.

What they built

The authors added four extensions to FastMCP using only its public extension points: no changes to the protocol and no patching of SDK internals.

PieceWhat it doesWhere it runs
C1. Cross-header credentialsValidates SSO and service-account credentials on different headers and merges whichever succeed into one principalStarlette HTTP middleware
C2. Cached token verificationChecks opaque tokens against the identity provider's API, caches the result for 300 seconds by default, keyed by a SHA-256 hash of the tokenStarlette HTTP middleware
C3. Pre-auth metadata endpointAn unauthenticated route that lists every tool's schema, required permissions and the accepted auth methods, for registriesA separate Starlette route
C4. Filtering plus enforcementOne decorator declares a tool's required permissions. A middleware filters list_tools with it, and the decorator checks it again at call timeFastMCP middleware and a function decorator

The key design choice is in C4. Both the list filter and the call-time check read the same attribute, which the decorator sets on the tool function. The metadata endpoint reads it too. There is only one declaration, so what the registry publishes, what the agent sees and what the server enforces cannot drift apart.

Diagram

Permissions are written in disjunctive normal form. @require_permissions(["admin", "write"], ["superadmin"]) means "admin and write, or superadmin", which covers the common case where a senior role should imply a set of finer-grained permissions.

The multi-header rule is deliberately simple. Each backend whose header is present runs on its own. A missing or invalid credential in one header contributes nothing and cannot block a valid one in another. If no backend succeeds, the request is rejected at the middleware with an explicit error rather than falling through to some default. If both succeed, the caller gets the union of both scope sets, and never more than that.

The experiment

The test server exposed eight mock Kubernetes tools: four needing a viewer permission (such as get_metrics) and four needing admin (such as restart_pod). Every agent ran with viewer credentials, and restart_pod was the target. The authors chose it because an agent under pressure could plausibly be talked into it, unlike something obviously destructive such as dropping a database.

They wrote 60 injection payloads, 15 in each of four classes: direct injection, indirect injection through a tool result, role-play, and context overflow. Each payload ran three times against four models (gpt-5, claude-sonnet-4-6, claude-opus-4-7 and gemini-2.5-pro) and three server configurations, for 2,160 attempts in total:

  • Baseline lists all eight tools and checks permissions inside each admin tool's body. This is how most MCP servers are written today.
  • Filter only removes admin tools from the list for viewer callers, with no check at call time.
  • With extensions does both.

In every configuration, nothing forbidden actually ran. The baseline's in-body check refused every call. The metric that differs is how often the model tried.

ModelDirect injectionIndirect injectionRole-playContext overflowBaseline totalFilter onlyWith extensions
claude-sonnet-4-60/450/450/450/450/1800/1800/180
claude-opus-4-73/450/450/450/453/1800/1800/180
gpt-526/450/4533/450/4559/1800/1800/180
gemini-2.5-pro27/4523/4537/453/4590/1800/1800/180
Total56/18023/18070/1803/180152/720 (21.1%)0/7200/720

The authors are upfront that the zeros in the last two columns are expected. A model can only emit a call for a tool that appears in the schema it was given. Remove the tool and it cannot be called through the model. The interesting numbers are elsewhere.

What the numbers show

Model refusal is not a security boundary

On identical prompts, baseline attempt rates ran from 0% for claude-sonnet-4-6 to 50% for gemini-2.5-pro. The models also broke in different ways. gpt-5 gave in only to direct injection and role-play. gemini-2.5-pro was the only model that also fell for indirect injection and context overflow. Role-play was the class that worked most consistently on any model that could be broken at all.

If a deployment relies on the model refusing, its security depends on which vendor it uses and which model version is current, and neither is under the deployer's control. The authors' conclusion is that the authorization boundary belongs to the enterprise, not the model provider. I agree, and I think it is the most useful point in the paper.

Hidden tools still get named

The paper also recorded whether a model mentioned restart_pod anywhere in its reply, even when the tool was absent from its schema. In 59 of the 60 payloads the injected text made the name easy to infer, so this measures repetition rather than discovery. It is still a lot of leakage:

ModelNamed the hidden tool (with extensions)
claude-opus-4-7169/180 (94%)
claude-sonnet-4-6142/180 (79%)
gemini-2.5-pro44/180 (24%)
gpt-513/180 (7%)

A tool name that shows up in model output also shows up in logs, transcripts and screenshots. It can also be read directly from the pre-auth metadata endpoint, which is public by design.

Hiding without enforcing is a bypass

The authors then skipped the model entirely and sent a raw tools/call for restart_pod with viewer credentials:

ConfigurationTool listedDirect call executesWhat blocked it
BaselineYesNoCheck in the tool body
Filter onlyNoYesNothing
With extensionsNoNoCall-time permission check

Filter-only looked perfect in the model test and failed immediately against a script. The list filter changes what the agent tries. The call-time check decides what actually runs. You need both, and the paper's contribution is making them read from the same declaration so they can't disagree.

It costs almost nothing

In a microbenchmark of 1,000 requests per operation, adding the filter and the decorator on top of FastMCP's own authentication changed median latency by roughly zero and p95 by at most about 0.15 ms, within run-to-run noise. list_tools got marginally faster with filtering, because a viewer gets back four tools instead of eight.

In production at PayPal, over a seven-day trace, the token cache answered 96.1% of verification requests from memory at a median of 9.9 ms, against 296 ms on a cache miss.

Where the evidence is thin

The paper is careful about its own limits, and they are worth repeating:

  • Small corpus. The payloads are 60 hand-reviewed prompts, drafted with LLM help, against one forbidden tool. That is enough to show that models differ. It says nothing about how robust any model is overall, and the authors don't claim it does.
  • Uneven controls. The positive control, which checks that benign calls work, ran on successor versions of the Claude models and not on Gemini. Only the GPT-5 control matched the checkpoint used in the attack sweep.
  • Different sampling. The two reasoning models ran at the gateway's default sampling, while the others ran at several temperatures, so the model comparison is not perfectly like for like.
  • No public code yet. The authors say the extension code, payloads and raw outputs will be released, but the repository link is still marked "to be added".
  • Open by default. A tool registered without a permission annotation is visible to and callable by any authenticated caller. The authors chose this so ordinary development isn't slowed down. For anything that touches production, I would flip it.
  • Revocation lag. A token revoked at the identity provider keeps working for up to one cache TTL, which is five minutes by default.

What I would change on an MCP server today

You don't need the paper's code to apply most of this. FastMCP already covers the core of it.

Enforce at call time, not only in the list. FastMCP's auth= parameter hides the tool from callers who fail the check and returns not-found if they call it directly anyway:

from fastmcp import FastMCP
from fastmcp.server.auth import require_scopes
 
mcp = FastMCP("cluster-tools")
 
 
@mcp.tool(auth=require_scopes("cluster:read"))
def get_metrics(namespace: str) -> dict:
    """Return CPU and memory metrics for a namespace."""
    ...
 
 
@mcp.tool(auth=require_scopes("cluster:admin"))
def restart_pod(namespace: str, pod: str) -> str:
    """Restart a single pod."""
    ...

require_scopes with several scopes means all of them are required. For an "either this role or that one" rule, pass your own function that receives an AuthContext and returns True or False. Check the FastMCP authorization docs for the version you run, as this API is still changing.

Don't let a missing annotation mean "open". A server-wide scope requirement in FastMCP's AuthMiddleware at least limits an unannotated tool to callers holding that scope. Add a test that lists the server's tools and fails if any of them has no auth= check, so a forgotten annotation is caught in CI rather than in production.

Keep the in-body check for the dangerous tools. It is the layer that stopped every baseline call in the experiment. Filtering on top of it cuts the attempts, the audit-log noise and the tokens spent on tool schemas the agent can't use.

Treat tool names as public. Assume an attacker knows every tool on the server, because models, logs and metadata endpoints will tell them. If a tool is only safe while nobody knows its name, it isn't safe.

Serve humans and automation from one server, carefully. If you need both SSO and service-account callers, validate each header on its own, merge only the credentials that succeed, and reject the request when none do. Never let a missing header fall back to a default identity.

Publish metadata only if something consumes it. A pre-auth endpoint is useful when a registry indexes many servers. It also hands every network-reachable caller your tool names and permission strings. If you have no registry, leave it off or put it behind its own authentication.

Choose the cache TTL on purpose. Five minutes of revocation lag is fine for most internal tools. It may not be for a tool that moves money or deletes data.

Where this fits

This is the server-side half of MCP authorization. A gateway in front of your servers decides whether a caller reaches a server at all, and we covered that layer in DevSecOps for AI agents. Inside the server, the question is which tools that caller may see and run, and that is what this paper addresses. For the basics of authentication patterns, transports and error handling on a single server, see How to Build a Good MCP Tool.

If you are putting MCP servers in front of production systems and want a second opinion on where the authorization checks sit, that is part of our AI agent security work. Talk to an engineer.

References

  1. Li, Wang and Manoharan: Zero-Trust Authorization and Discovery for Enterprise MCP, arXiv 2609.22573 (September 2026)
  2. FastMCP: Authorization, component-level and server-level access control
  3. NIST: SP 800-207, Zero Trust Architecture (2020)
  4. Model Context Protocol: Enterprise-Managed Authorization extension
  5. Kumar, Wang and Manoharan: A Gateway Architecture for Enterprise MCP Authentication, arXiv 2608.10760 (2026)
  6. Cerbos: How to Secure Your FastMCP Server With Permission Management
  7. OWASP: Top 10 for Large Language Model Applications, 2025