# With OAuth alone, any signed-in employee can make the agent refund

> A hands-on FastMCP build adds per-tool authorization, so a clerk can look up tickets, a shift lead can refund and a stray token gets nothing.

Original: https://fdetimes.net/en/guides/mcp-server-oauth-per-tool-authorization/

A customer support agent types into the AI agent: "refund ticket 4512 for me". The agent calls the `issue_refund` tool on the MCP server, and the transaction goes through. Nobody attacked anything. The employee signed in legitimately; the server simply never asked whether they were allowed to issue refunds.

OpenFGA's documentation states the risk plainly: without an authorization layer, any authenticated user can call any tool.

For an FDE, this is very likely the question a customer's security team wants answered before letting an agent touch real data. This article walks through each step so you can answer it with working code rather than slides.

## What will you build, and what do you need?

The running example is a hypothetical helpdesk MCP server with two tools: `lookup_ticket` (read-only) and `issue_refund` (writes). There are three users. An is a support agent who may only look things up. Binh is a shift lead who may do both. Chi works on another team and holds only a token issued for the CRM system, not for this server.

You need Python, the FastMCP library, an authorization server that supports OAuth 2.1 (the customer's identity provider, or a local instance for testing) and `curl`. The code below is simplified to illustrate the ideas. Check the exact import and attribute names against the FastMCP documentation before using them.

## Step 1: separate the roles; the server does not issue tokens

The MCP spec, version 2025-06-18, defines a protected MCP server as an OAuth 2.1 resource server. Issuing tokens belongs to the authorization server. Your server stores no passwords and has no login screen. Its job is to receive a token, validate it, and make a decision.

That is why the spec requires MCP servers to implement OAuth 2.0 Protected Resource Metadata (RFC 9728). This document tells the client which authorization server to request a token from. Without it, the client has no way to discover the login path on its own.

## Step 2: return a 401 that tells the client where to go

When a request arrives without a token, the spec requires the server to return *401 Unauthorized* with a `WWW-Authenticate` header indicating the location of the metadata URL. This is the first thing to check:

```bash
curl -i https://mcp.example.test/mcp
```

The output should include a `401` line and a `WWW-Authenticate` header containing a URL. Then `curl` that URL and confirm it returns metadata pointing to your authorization server. If the first request gets a 200, the server is open to the whole world.

On the client side, the spec requires PKCE and the `resource` parameter in the OAuth flow. After that, the token must accompany every HTTP request, including requests within the same logical session, and must never be placed in the query string.

## Step 3: validate the token, especially its audience

A valid signature alone is not enough. The spec requires the server to check that the token was issued specifically for it, meaning the audience is this server (RFC 8707). Expired or invalid tokens must get a 401.

This is exactly Chi's case. Chi's token was issued by the same identity provider and carries a valid signature, but it was issued for the CRM system. A server that checks only the signature would accept it by mistake. MCP's Security Best Practices document explicitly forbids this: a server must not accept any token not issued specifically for it.

The rule applies in the other direction too. When `issue_refund` needs to call the payment API behind it, do not pass the user's token straight through (token passthrough). Use a separate token issued for that call.

There is one more trap. The same document requires the server to verify every inbound request and not to use sessions for authentication. A session ID should only hold context, and should be bound to the user ID taken from the token, so that someone who obtains another person's session cannot use it in their place.

## Step 4: attach scopes to each tool

At this point the server knows who is calling. The next question is what they may call. FastMCP lets you attach a condition to each tool through the `auth` parameter. According to the FastMCP documentation, a scope check requires the token to hold every specified scope. Having just one of them is not enough.

The documentation also says an auth check can be any function that takes an `AuthContext` and returns a boolean. That lets you write both scope-based and per-user rules yourself:

```python
# Illustrative, simplified: check attribute names against the FastMCP docs
from fastmcp import FastMCP

mcp = FastMCP("helpdesk")

def has_scopes(*required):
    def check(ctx) -> bool:          # ctx: AuthContext
        scopes = set(ctx.token.scopes)  # simplified
        return all(s in scopes for s in required)
    return check

@mcp.tool(auth=has_scopes("tickets:read"))
def lookup_ticket(ticket_id: str) -> dict: ...

@mcp.tool(auth=has_scopes("refunds:write"))
def issue_refund(ticket_id: str) -> dict: ...
```

The detail worth noting is how FastMCP handles a tool the caller lacks permission for: the tool is hidden from the list, and a direct call returns not-found. For An, the agent does not even know `issue_refund` exists. It cannot accidentally call a tool it cannot see.

**Key point:** Being able to sign in does not mean being allowed to call every tool.

## Step 5: scopes are not enough, so add per-user rules

MCP's security documentation lists, among common mistakes, treating the scopes in a token as sufficient without server-side authorization logic. The reason is mundane: the token is issued by another party and may be granted generously. Only your server knows whether Binh is still a shift lead.

OpenFGA describes a relationship-based authorization pattern: show users only the tools they may use, then check again when a tool is called, because permissions may have changed in the meantime. Applied to the example, you write a new rule and replace the old condition on `issue_refund` with it:

```python
# Illustrative: is_shift_lead() queries your authorization system
def shift_lead_with_scope(ctx) -> bool:
    user_id = ctx.token.claims["sub"]   # simplified
    return has_scopes("refunds:write")(ctx) and is_shift_lead(user_id)

# Replace auth=has_scopes("refunds:write") from Step 4 with the new rule
@mcp.tool(auth=shift_lead_with_scope)
def issue_refund(ticket_id: str) -> dict: ...
```

Because FastMCP applies the `auth` condition both when filtering the list and when a tool is called directly, Binh, who has the scope but has been removed from the shift lead role, can no longer use `issue_refund`. The goal is a matrix in which every cell has a test:

| User | lookup_ticket | issue_refund |
|---|---|---|
| An (support agent, read scope only) | can call | hidden; a direct call gets not-found |
| Binh (shift lead, full scopes) | can call | can call |
| Binh, role removed mid-session | can call | blocked at call time, gets not-found |
| Chi (other team, token issued for the CRM) | 401 | 401 |

The third row is the one most often missed, and it is why OpenFGA stresses re-checking at call time.

## 401, 403 or not-found: what does the client actually see?

The spec draws a clear line: 401 is for a missing, expired or invalid token, while 403 is for a valid token with the wrong scopes or insufficient permissions. Return a 401 for a permissions problem and the client will keep asking the user to sign in again while the problem stays exactly where it was.

Keep the two layers apart. 401 and 403 are HTTP codes, used when the server refuses at the front door: Chi's token gets a 401 here. At the tool layer, the decision belongs to the `auth` condition seen in Step 4, so when writing tests, state for each cell which code you expect and at which layer.

MCP's security documentation also recommends starting with minimal scopes and expanding when needed by requesting additional scopes via `WWW-Authenticate`. In the example, everyone starts with only `tickets:read`, and `refunds:write` is requested only when someone actually needs to issue a refund.

Before handover, review the five mistakes to avoid from the steps above: checking only the signature and ignoring the audience, passing the token through to downstream APIs, trusting the session instead of checking every request, trusting scopes completely, and filtering the tool list without re-checking at call time.

## What does this skill look like at a customer site?

Usually the identity provider already exists and is not yours to control. Your job is to sit down with the IAM team and agree on the audience, the list of scopes and where relationships such as "who is a shift lead" are stored. Bring the user × tool matrix to the very first meeting, because it is the most concrete thing for the security team to review.

To demonstrate the skill in interviews, put a small MCP server on GitHub with a test for each cell of the matrix, and add a line to your CV such as: "per-tool authorization for an MCP server, audience validation, separating 401/403 from tool-level not-found, re-checking permissions at call time".

When you read a job description mentioning OAuth, MCP or "least privilege", you will have a concrete example ready to talk about.

The more an agent can do, the more the question "who is allowed to tell it to do what" matters. An FDE who can answer it with a test table that is all green is the one customers will trust with real data.

**Try this week:**

- Build an MCP server with two tools (one read, one write), send a curl request without a token and confirm the response is a 401 with a WWW-Authenticate header.
- Draw up a user × tool matrix for a system you are working on, then write a test for each cell: expired token, wrong audience, missing scope, wrong role. State which code each cell expects.
- Read the Security Best Practices section of the MCP spec, check each point against your server and note what is still missing.

## Sources

- [Authorization – Model Context Protocol Specification (2025-06-18)](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)

- [Security Best Practices – Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices)

- [Authorization – FastMCP](https://gofastmcp.com/servers/authorization)

- [Authorization for MCP Servers – OpenFGA](https://openfga.dev/docs/modeling/agents/mcp-authorization)
