Introduction
Model Context Protocol (MCP) servers turn ordinary functions into things an autonomous agent can call. That shift sounds small, but it inverts a security assumption almost every backend engineer has relied on for two decades: that the caller is a human, moving deliberately, through a UI that only exposes the actions they're allowed to take. An MCP client isn't a human clicking buttons. It's a language model deciding, based on a prompt, a document it just read, or a plan it generated three steps ago, that it should call delete_customer or rotate_api_key. If your server doesn't check whether that specific call is authorized at the moment it happens, the model's intentions - good or manipulated - become your access control policy.
This is, at its core, an old vulnerability wearing new clothes. Missing function-level access control has been on the OWASP Top 10 in one form or another for years, catalogued formally as CWE-862 (Missing Authorization) and CWE-280 (Improper Handling of Insufficient Permissions or Privileges). What's new is the blast radius. A missed authorization check on a REST endpoint might expose one resource to one attacker who found the URL. A missed authorization check on an MCP tool is exposed to every agent, every prompt injection payload, and every chained tool call that touches your server - and the "attacker" doesn't need to guess a URL, because the tool's name and schema are handed to it in the tools/list response by design. This article walks through why that matters, what it looks like in code, and how to close the gap using FastMCP, middleware like Eunomia and Permit.io, and MCP's elicitation feature.
The Problem: What Missing Function-Level Access Control Looks Like in MCP
Function-level access control means verifying, for every distinct operation, that the specific caller is allowed to perform that specific action on that specific resource - not just that they're logged in. It's the difference between authentication ("who are you?") and authorization ("are you allowed to do this?"). Most access control failures in production systems aren't sophisticated exploits; they're simply an endpoint or function where someone forgot to add the check, or added it once at the wrong layer and assumed it would apply everywhere else.
MCP servers are unusually prone to this failure pattern because tools are often written the way internal scripts are written: quickly, by whoever needed the capability, without threat-modeling who else might invoke it. A tool like refund_order, send_email, or execute_query gets built to solve one workflow, gets registered with @mcp.tool(), and is now callable by any client that can reach the server and knows the tool exists - which, again, is everyone who calls tools/list, because that's the entire point of MCP's discovery mechanism. There is no browser chrome hiding the "admin" button from regular users. There is no client-side routing that only renders privileged menu items for privileged accounts. The tool is either protected by a server-side check, or it isn't protected at all.
The failure compounds in agentic contexts because the calling "user" is frequently a chain of delegation: a human asked an agent to do something, the agent decided to call a tool, and the tool might itself call another MCP server on the agent's behalf. Each hop is an opportunity for the original intent to get distorted - by ambiguous instructions, by a compromised upstream tool result, or by a prompt injection embedded in a document the agent read earlier in its context. Function-level access control is the backstop that holds regardless of how confused or manipulated the calling chain became upstream of it.
Why This Matters More for MCP Than for Traditional APIs
In a conventional API, obscurity offers a thin, unreliable layer of protection: an endpoint that isn't documented or linked anywhere is harder for a casual attacker to find. That protection was always weak - security through obscurity is not security - but MCP removes even the pretense of it. The protocol's tools/list method exists specifically to advertise every capability a server offers, complete with names, descriptions, and input schemas, because that's how an agent figures out what it can do. Anything you expose is, by construction, discoverable by any connected client. Treating an unlisted or vaguely-named tool as "hidden" is a category error; MCP was designed to eliminate hidden endpoints, not preserve them.
The second reason MCP raises the stakes is that the caller's behavior is non-deterministic. A traditional client sends the requests a developer coded it to send. An MCP client - the agent - sends whatever requests its current reasoning concludes are appropriate, and that reasoning can be steered by content the server's own tools return. A tool that reads untrusted external data (a support ticket, a web page, a file) and passes that content back into the agent's context can effectively become an attacker's delivery mechanism, instructing the agent to call a destructive tool it was never meant to reach in that session. Function-level access control is what prevents that instruction from turning into an authorized action, because the check happens at the server boundary regardless of why the agent decided to make the call.
The CWE Lens: CWE-862 and CWE-280
It's worth being precise about the two weaknesses this article keeps returning to, because they describe different failure modes and both show up in MCP servers regularly. CWE-862, Missing Authorization, describes the case where the product does not perform an authorization check when an actor attempts to access a resource or perform an action. This is the pure case: nobody wrote the check. A tool handler executes the business logic - deletes the record, sends the email, runs the query - without ever asking whether the caller's identity or role permits that specific action. This is distinct from performing an authorization check that is flawed or bypassable - in CWE-862, the check is completely absent.
CWE-280, Improper Handling of Insufficient Permissions or Privileges, describes a subtler and arguably more dangerous case: the product does not handle, or incorrectly handles, situations where it has insufficient privileges to access resources or functionality as specified by permissions, which can cause it to follow unexpected code paths that leave the product in an invalid state. In an MCP context, this shows up when a permission check exists but the failure path is wrong - for example, a tool that checks the caller's scope, finds it lacking, but then falls back to executing a default or cached code path instead of raising a clean authorization error. CWE-280 is why MITRE's own guidance stresses always verifying that an operation actually succeeded and handling the failure explicitly, even when operating in a highly privileged mode, because errors or environmental conditions might still cause a failure. An MCP tool that silently swallows a permission failure and returns a partial or stale result can be just as dangerous as one with no check at all, because the calling agent has no signal that anything went wrong and will act on the result as if it were authoritative.
Both weaknesses map cleanly onto OWASP's Broken Access Control category, which has topped the OWASP Top 10 web application risks list since 2021 - a useful reminder that this is not a novel AI-security problem, it's a decades-old problem showing up in a new transport.
Anatomy of an Insecure FastMCP Server
The following example is deliberately realistic rather than contrived. It's the kind of tool that gets written when a team is moving fast: it works, it passes a manual test, and the danger is invisible unless you're specifically looking for it.
from fastmcp import FastMCP
mcp = FastMCP("Support Ops Server")
# Looks reasonable: it's authenticated, right?
@mcp.tool()
def issue_refund(order_id: str, amount_usd: float) -> str:
"""Issue a refund for a customer order."""
# No check on WHO is calling this, or whether they're allowed
# to issue refunds, or whether this amount exceeds their limit.
process_refund(order_id, amount_usd)
return f"Refunded ${amount_usd} for order {order_id}"
@mcp.tool()
def _admin_reset_account(user_id: str) -> str:
"""Internal tool, not meant for general use."""
# The leading underscore and vague docstring are the ONLY
# protection here. It's still fully callable via tools/call.
reset_account(user_id)
return f"Account {user_id} reset"
Two distinct problems sit in this snippet, and they map directly onto the two mitigation principles the industry keeps repeating: this is CWE-862 in its purest form, and it's a textbook violation of "don't rely on hidden URLs or APIs." issue_refund never asks who is calling it, what role they hold, or what refund ceiling applies to them - any client connected to this server can drain the refund queue at any amount, and the server has no way to distinguish a support agent's legitimate call from an agent that was manipulated by a crafted support ticket into issuing refunds to an attacker-controlled account. There is no authentication context checked, no scope required, nothing.
_admin_reset_account is the more instructive failure because it feels safer than it is. The leading underscore and the phrase "not meant for general use" are conventions a human reader would respect; they mean nothing to the MCP protocol or to an agent parsing a schema. The function is registered with @mcp.tool() exactly like any other, so it appears in tools/list and is invokable through tools/call by any connected client. This is exactly the pattern the mitigation "do not rely on hidden URLs or APIs" is warning against: obscurity is not access control, and MCP's discovery-first design means there effectively is no obscurity to rely on in the first place.
Rebuilding It Securely: Deny-by-Default and Least Privilege
Fixing this requires three things working together: an authentication layer that establishes who is calling, a per-tool authorization check that runs on every single invocation rather than once at connection time, and a default posture that denies access unless a rule explicitly grants it. FastMCP supports bearer-token authentication out of the box through BearerAuthProvider, which validates a JWT's signature, issuer, audience, and expiry before a request is even routed to a tool handler, and which can require specific OAuth scopes at the server or tool level.
from fastmcp import FastMCP, Context
from fastmcp.server.auth import BearerAuthProvider
from fastmcp.server.dependencies import get_access_token
from fastmcp.exceptions import ToolError
auth = BearerAuthProvider(
jwks_uri="https://auth.example.com/.well-known/jwks.json",
issuer="https://auth.example.com",
audience="support-ops-mcp",
algorithm="RS256",
required_scopes=["support:read"], # baseline scope for ANY access
)
mcp = FastMCP("Support Ops Server", auth=auth)
REFUND_LIMITS = {"tier1_agent": 200.00, "tier2_agent": 2000.00, "supervisor": 25000.00}
@mcp.tool(required_scope="support:refund")
def issue_refund(ctx: Context, order_id: str, amount_usd: float) -> str:
"""Issue a refund for a customer order."""
token = get_access_token()
if token is None:
raise ToolError("Authentication required")
role = token.claims.get("role")
limit = REFUND_LIMITS.get(role)
if limit is None or amount_usd > limit:
# Deny by default: absence of an explicit, sufficient
# grant is treated as "no", not "yes".
raise ToolError(f"Refund of ${amount_usd} exceeds authorization for role '{role}'")
process_refund(order_id, amount_usd)
return f"Refunded ${amount_usd} for order {order_id}"
# The admin tool is gated by scope, not by naming convention.
@mcp.tool(required_scope="admin:accounts")
def admin_reset_account(user_id: str) -> str:
"""Reset an account. Requires admin:accounts scope."""
reset_account(user_id)
return f"Account {user_id} reset"
Three design decisions here are doing the real work, and they generalize past this one example. First, authorization is evaluated inside the tool body on every call, reading claims off the token that arrived with this request - it is not a one-time check performed when the connection was established, which matters because a session can outlive the conditions under which it was granted (a role change, a revoked grant, a suspended account). Second, the refund limit check is structured so that an unrecognized role or a missing limit resolves to denial, not to some default allowance - that's least privilege and deny-by-default in the same line of code. Third, the admin tool's protection lives in required_scope, a mechanism the framework enforces before the function body even runs, rather than in a naming convention or docstring warning that carries no enforcement weight.
Authorization Middleware: Eunomia and Permit.io
Hand-rolling scope checks inside every tool works, but it scales poorly once you have dozens of tools, multiple roles, and policies that need to change without a redeploy. FastMCP addresses this with a middleware pipeline: middleware forms a pipeline around the server's operations, where each request flows through each middleware in order, and each can inspect, modify, or reject the request before passing it along, with the response flowing back through the same middleware in reverse order. Two third-party authorization projects plug directly into this pipeline as FastMCP integrations: Eunomia and Permit.io.
Eunomia is a purpose-built, open-source authorization layer for MCP servers, and its middleware does more than gate execution - it intercepts all MCP requests and automatically maps MCP methods to authorization checks, acting as a filter for listing operations like tools/list by hiding unauthorized components from the client, and as a firewall for execution operations like tools/call by blocking anything not authorized by the defined policies. That dual behavior is important: it means Eunomia enforces the rule at both the discovery layer and the execution layer, so a tool that's hidden from a client's tools/list response is also blocked if that client tries to call it directly - closing exactly the "security by obscurity" gap that a naming-convention-only approach leaves open.
from fastmcp import FastMCP
from eunomia_mcp import create_eunomia_middleware
mcp = FastMCP("Secure Support Ops Server")
@mcp.tool()
def issue_refund(order_id: str, amount_usd: float) -> str:
"""Issue a refund for a customer order."""
process_refund(order_id, amount_usd)
return f"Refunded ${amount_usd} for order {order_id}"
middleware = create_eunomia_middleware(policy_file="mcp_policies.json")
mcp.add_middleware(middleware)
Permit.io takes a similar integration shape but leans on a richer policy model - its middleware leverages FastMCP's middleware system to intercept all MCP requests and automatically map MCP methods to authorization checks against Permit.io policies, covering both server methods and tool execution, using Permit.io's RBAC, ABAC, and REBAC capabilities. That relationship-based access control (REBAC) support matters for cases plain role checks can't express cleanly, like "an agent can only read documents owned by or explicitly shared with the requesting user," which is a common requirement once MCP tools start touching multi-tenant data. Both integrations produce the same operational win: policy changes live in a policy file or a hosted policy engine, not scattered across if statements in tool bodies, so a security team can tighten or loosen access without redeploying the server.
Token Expiry, Agent Identity, and Elicitation for High-Impact Actions
Authorization checks are only as trustworthy as the identity they're checking against, which is why token expiry enforcement isn't optional plumbing - it's the mechanism that turns "who is this claim about" into "who is this claim about, right now." FastMCP's BearerAuthProvider validates a JWT's signature, issuer, audience, and expiry automatically on every request; a request presenting an expired token is rejected before it ever reaches middleware or tool code. This matters specifically for agentic sessions because they can run far longer than a typical human web session - an agent working through a multi-step task might hold a session open for minutes or hours, and a token that doesn't expire (or a server that doesn't check expiry) turns a single leaked credential into standing access rather than a bounded window of risk.
Identifying the calling agent - as distinct from the human it's acting on behalf of - is the piece most teams skip, and it shows up as a gap between "we have authentication" and "we know what's actually calling us." FastMCP's authorization layer supports checks against arbitrary claims in the access token, which is the right place to encode agent identity: a token's claims dictionary can carry not just the end user's identity but which agent framework, which deployment, or which specific automation initiated the session, and a tool can require that a particular claim be present before allowing a sensitive operation. Eunomia's middleware follows the same pattern at the transport layer, extracting agent identification through headers like X-Agent-ID or User-Agent so that policies can differentiate a known internal agent from an unrecognized or third-party one, even when both present a technically valid user token.
Even with solid authentication and identity, some actions are risky enough that no policy engine should approve them unattended - deleting a production database, wiring a large payment, or resetting a customer's credentials. This is where MCP's elicitation feature earns its place in a defense-in-depth design: by enabling bidirectional communication, elicitation allows for more sophisticated and secure interactive workflows, from confirming a critical action like a financial transaction to simply clarifying an ambiguous request. In FastMCP, a tool pauses mid-execution, sends a structured request back to the client, and only proceeds once a human explicitly responds.
from fastmcp import FastMCP, Context
mcp = FastMCP("Support Ops Server")
@mcp.tool(required_scope="support:refund")
async def issue_large_refund(ctx: Context, order_id: str, amount_usd: float) -> str:
"""Issue a refund; amounts above $1,000 require human confirmation."""
if amount_usd > 1000.00:
result = await ctx.elicit(
f"Confirm refund of ${amount_usd:.2f} for order {order_id}?",
response_type=bool,
)
if result.action != "accept" or not result.data:
return "Refund cancelled: confirmation not received"
process_refund(order_id, amount_usd)
return f"Refunded ${amount_usd} for order {order_id}"
Elicitation doesn't replace authorization - a caller without the support:refund scope never reaches this code at all - it adds a human checkpoint on top of a caller who is already authorized, specifically for the subset of actions where "technically permitted" and "safe to execute without a second look" aren't the same thing.
Trade-offs and Pitfalls
None of this is free, and pretending otherwise sets teams up to cut corners later. Deny-by-default authorization means every new tool ships with zero access until someone explicitly grants it, which is exactly the friction you want from a security standpoint but is genuinely slower for a team trying to ship a proof of concept. The honest failure mode to watch for isn't "we chose deny-by-default and regretted it" - it's teams quietly reverting to allow-by-default during a demo or hackathon sprint and never reverting back before the server touches real data.
Middleware-based authorization (Eunomia, Permit.io, or a custom equivalent) introduces its own risks if adopted carelessly. A remote policy decision point adds latency and a new dependency to your request path - if the authorization service is unreachable, the server needs an explicit, tested answer for what happens next, and that answer must be "deny," never a fallback to open access, or you've reintroduced CWE-280 at the infrastructure layer. Centralizing policy also concentrates risk: a misconfigured policy file that's too permissive now affects every tool on the server at once, rather than being isolated to whichever single function a developer forgot to guard. This is a reasonable trade against the alternative of inconsistent, hand-rolled checks scattered across dozens of tools, but it needs to be paired with policy review discipline and staging environments, not treated as a fire-and-forget install.
Best Practices for Engineering Teams
Pulling the threads together, a handful of concrete habits cover most of the risk surface discussed above. Every tool should require an explicit grant before it's callable, rather than being reachable by default and relying on someone remembering to lock it down later - this is deny-by-default in practice, and it should be a property of your framework configuration, not a convention developers are trusted to remember.
Authorization checks belong inside the request path, evaluated against the token or context that arrived with that specific call, not cached from an earlier connection-time check or inferred from the fact that a tool simply exists on the server. Pair that with the least-privilege principle at the token-issuance layer: scopes and roles should be narrow enough that a compromised or manipulated agent session can only do a bounded amount of damage, and refund limits, resource ownership, or tenant boundaries should be encoded as claims the server actually checks rather than assumptions baked into the UI a human normally uses. Token expiry should be short enough that a leaked credential has a small window of usefulness, and long-running agent sessions should refresh rather than hold a single token indefinitely.
Finally, treat tool naming, docstrings, and "internal-only" comments as documentation for humans, never as security controls - anything registered on an MCP server is discoverable and callable, so the only real protection is a server-side check, and any operation whose consequences are hard to reverse (deletion, payment, credential reset, irreversible external side effects) deserves an elicitation step even after authorization passes, because "allowed" and "should happen without a second look" are different questions.
Mental Models: Access Control Like a Bouncer, Not a Receptionist
A useful way to explain this to a team that's new to MCP is the difference between a receptionist and a bouncer. A receptionist's job is to direct people to the right door - they're helpful, they assume good intent, and their entire model of security is "if you know which door to ask for, you're probably supposed to be there." A naming convention like _admin_reset_account is a receptionist: it politely suggests you shouldn't go in, but it opens the door for anyone who asks by name, which in MCP's case is anyone who calls tools/list.
A bouncer checks the same ID against the same list at every single door, every single time, regardless of who vouched for the person or which door they're trying. That's the model function-level access control needs: not a single check at the building entrance (session establishment) that then trusts the visitor to behave for the rest of the night, but a check repeated at every room that matters, evaluated against credentials that can be revoked, expired, or found insufficient at any moment. Middleware like Eunomia or Permit.io is, in this analogy, less like a bouncer and more like a centralized security office that every bouncer in the building radios before making a call - consistent policy, applied uniformly, updatable without retraining every bouncer individually.
Key Takeaways
- Require an explicit scope or role grant for every MCP tool, and make the default state "denied" - never wire a tool to be callable simply because it exists and no one added a check yet.
- Evaluate authorization inside the tool handler on every call, reading the caller's current token claims, rather than relying on a check performed once at session or connection time.
- Never treat tool naming, docstrings, or omission from documentation as protection - anything registered with
@mcp.tool()is discoverable throughtools/listand callable throughtools/callby any connected client. - Enforce short-lived, verifiably-signed tokens (via
BearerAuthProvideror equivalent) and identify the calling agent through claims or headers distinct from the end user's identity, so policies can distinguish a known automation from an unrecognized one. - Add
ctx.elicit()human-confirmation steps for irreversible or high-impact operations - refunds above a threshold, deletions, credential resets - even for callers who already passed authorization, because "permitted" and "safe to run unattended" are not the same guarantee.
Conclusion
Missing function-level access control isn't a new category of vulnerability that MCP invented; it's CWE-862 and CWE-280, the same weaknesses that have driven Broken Access Control to the top of the OWASP Top 10 for years, showing up in a protocol that removes the last remaining excuse for skipping the check. There's no obscure endpoint to hide behind, no client-side menu to keep a button out of sight - MCP's discovery model puts every tool's name and schema directly in front of whatever client connects, human-operated or agent-operated, well-intentioned or manipulated by a prompt injection three steps upstream.
The fix is neither exotic nor especially difficult once you commit to it: deny by default, grant the minimum scope each caller actually needs, check authorization on every request against the token that arrived with it, and never mistake a naming convention for a security boundary. FastMCP gives you the primitives to do this natively through BearerAuthProvider, per-tool required_scope, and claim-based checks, and middleware projects like Eunomia and Permit.io let you centralize and scale those policies as the number of tools and roles grows. Layer elicitation on top for the handful of operations where a correct authorization decision still deserves a human's final word, and you've addressed the actual threat model MCP introduces - not an agent that's malicious, but a server that never learned to ask "should I?" before it asked "can I?"
References
- MITRE CWE-862: Missing Authorization - https://cwe.mitre.org/data/definitions/862.html
- MITRE CWE-280: Improper Handling of Insufficient Permissions or Privileges - https://cwe.mitre.org/data/definitions/280.html
- OWASP Top 10:2021 - A01:2021 Broken Access Control - https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- Model Context Protocol Specification - Authorization - https://modelcontextprotocol.io/specification/draft/basic/authorization
- FastMCP Documentation - Authorization - https://gofastmcp.com/servers/authorization
- FastMCP Documentation - Middleware - https://gofastmcp.com/servers/middleware
- FastMCP Documentation - User Elicitation - https://gofastmcp.com/servers/elicitation
- FastMCP Documentation - Eunomia Authorization Integration - https://gofastmcp.com/integrations/eunomia-authorization
- FastMCP Documentation - Permit.io Authorization Integration - https://gofastmcp.com/integrations/permit
- Eunomia - Open-Source Authorization Layer for AI Agents (GitHub) - https://github.com/whataboutyou-ai/eunomia
- Permit.io - Fine-Grained Permissions for AI-Powered Applications - https://www.permit.io/ai-access-control
- Permit.io Documentation - MCP Permissions - https://docs.permit.io/ai-security/mcp-permissions/
- WorkOS - MCP Elicitation: Request User Input at Runtime - https://workos.com/blog/mcp-elicitation