Two servers, one authorization server
Picture two MCP servers on a research team. evals-mcp lets agents read eval results. cluster-mcp lets agents launch training and eval jobs, which spends GPU hours from a shared budget. Both trust the same authorization server, so both can verify the same signatures.
An agent holds a token issued for evals-mcp. It is correctly signed, unexpired and carries a jobs:launch scope because someone was generous when they set up the client. The agent, or anyone who copied the token from a log, sends it to cluster-mcp. Should cluster-mcp launch the job?
No. A valid signature proves the claims were not altered. It does not prove this server is among the token's intended recipients. The audience claim has to name the server that is receiving the request.
What the spec requires
The MCP authorization specification (version 2025-11-25) applies to HTTP transports and builds on OAuth 2.1. Four of its requirements carry this article:
- Clients must send the
resourceparameter from RFC 8707, naming the server they intend to use the token with, in both the authorization and the token request. - Servers must validate that a token was issued specifically for them as the intended audience, and reject tokens that do not include them.
- Invalid or expired tokens must get HTTP 401. A valid token with too little scope should get HTTP 403 with an
insufficient_scopeerror. - A server that calls an upstream API acts as a separate OAuth client there. It must not pass through the token it received.
The spec names both failure modes. A server that skips the audience check accepts tokens minted for other services. A server that also forwards those tokens downstream turns into a confused deputy: the downstream API may trust a token as if the MCP server had vouched for it. Checking the caller's authority, not the service account's covers that deputy from the application side.
Four predicates, one table
A bearer token proves possession under its validation rules, not who is holding it. Before trusting any claim in a signed token, verify the algorithm, the issuer and the key. Then answer four separate questions:
| Check | Question it answers | Failure if skipped |
|---|---|---|
| Signature and issuer | Did a trusted issuer sign these claims unaltered? | Forged tokens accepted |
| Audience | Was this token issued for this server? | A token for one server works against another |
| Expiry | Is it still inside its validity window? | Stale credentials keep working |
| Scope | Does it permit this operation? | A read-only token launches jobs |
Scroll horizontally to view columns.
Some access tokens are opaque and are validated by asking the issuer rather than by checking a signature locally. Do not assume every OAuth access token is a JWT. And some profiles allow several audiences in one token; the rule is that this server must be one of them.
A fixture you can run
The claims below stand in for a token whose signature and issuer a separate step has already verified. Never build them from unverified request JSON in a real server. The clock is fixed so the output is the same on every run:
from dataclasses import dataclass
NOW = 1_000_000 # a fixed clock, so the output is the same on every run
@dataclass(frozen=True)
class Claims:
audience: frozenset # a token may name several recipients
scope: frozenset
expires_at: int
SERVER = "https://cluster-mcp.lab.example"
launched = []
def check(claims, required_scope, now=NOW):
# Assumes signature and issuer were already verified by a separate step.
if SERVER not in claims.audience:
return 401, "token not issued for this server"
if claims.expires_at <= now:
return 401, "token expired"
if required_scope not in claims.scope:
return 403, f"insufficient_scope: needs {required_scope}"
return 200, "ok"
def launch_job(claims, spec):
status, reason = check(claims, "jobs:launch")
if status == 200:
launched.append(spec) # the protected work: spending GPU hours
return status, reason
cases = {
"wrong audience": Claims(frozenset({"https://evals-mcp.lab.example"}), frozenset({"jobs:launch"}), NOW + 3600),
"expired": Claims(frozenset({SERVER}), frozenset({"jobs:launch"}), NOW - 10),
"missing scope": Claims(frozenset({SERVER}), frozenset({"jobs:read"}), NOW + 3600),
"positive control": Claims(frozenset({SERVER}), frozenset({"jobs:launch"}), NOW + 3600),
}
for name, claims in cases.items():
before = len(launched)
status, reason = launch_job(claims, {"gpus": 8})
print(f"{name:16} {status} {reason:38} jobs launched +{len(launched) - before}")wrong audience 401 token not issued for this server jobs launched +0
expired 401 token expired jobs launched +0
missing scope 403 insufficient_scope: needs jobs:launch jobs launched +0
positive control 200 ok jobs launched +1I ran it with Python 3.12 on September 30, 2026; it pastes into a Colab cell. Each denial case changes exactly one claim and keeps the other two valid. That is the design choice worth copying. A token that is both expired and for the wrong server is refused at the first check and tells you nothing about whether the expiry check works. The positive control is what makes the three zeros mean something: a validator that refused everything would also produce three zeros, and only the +1 row rules that out.

The diagram is the full server boundary. The fixture covers only the audience, expiry and scope part. A real library may combine several checks in one call, so test behavior, not function layout.

The figure's last box matters. A token that passes all four checks says this server may accept requests with this scope. It does not say that this particular job, on this particular budget, is allowed. The application's own permission check still runs.
The tempting shortcut is to forward the client's token to a downstream API because it was "already validated." The spec forbids it for a reason: the token was validated for one boundary and never checked against the next one it crosses. Each server obtains its own token for its own upstream calls.
List the server's canonical URI (its audience value), the scope each tool requires, the maximum token lifetime you accept, and how an early revocation reaches the server.
Wrong audience, expired, missing scope: each with the other claims valid, each asserting that the protected counter did not move.
One fully valid token must do the protected work exactly once.
Mint real test tokens from your authorization server for a second test resource and replay them. The fixture proves your predicate order; only this step proves your configuration.
Check the decision
Why this matters in a lab
Research teams wire agents to internal services: eval stores, job schedulers, dataset buckets, model registries. MCP over HTTP is one common way to do it, and every one of those servers is a place where one token can be replayed against another. The two servers in this article differ in consequence: one reads results, the other spends compute. That asymmetry is typical, and it is why the cheaper server's tokens must not work on the expensive one.
In an interview, "how does an MCP server decide whether to trust a request" has a short strong answer: validate the token's signature and issuer, check that its audience names this server, check expiry and scope, return 401 or 403 as the spec says, then run the application's own permission check. Never forward the client's token upstream. Chapter 14 builds the client side of MCP over stdio. This page covers the part that chapter leaves out on purpose: authorization for servers you reach over a network.
What would prove this wrong
If your deployed server accepts the wrong-audience token, it fails the spec's requirement. Check the configured audience value first (trailing slashes and scheme case are common mismatches; the spec recommends the form without a trailing slash), then any middleware that skips validation on some routes, before you suspect the crypto library.
None of the numbers above describe a real server. They describe four inputs to one Python function. Revocation is a fifth question this fixture does not answer: a token can be correctly audienced, unexpired and scoped, and still need rejecting because it was revoked early. Whether that works depends on the revocation mechanism your deployment declares, short lifetimes or an introspection call, and it needs its own test.
Build a bounded MCP client where discovery describes tools and the application decides which may run.

