docs/advanced/authorization.md
Over Streamable HTTP your MCP server is an ordinary web service, and you protect it the way you protect any web service: with OAuth 2.1 bearer tokens.
In OAuth terms, your server is a resource server. It never signs anyone in and it never issues a token. It does one thing: look at the Authorization header on each request and decide whether the token in it is good.
Authorization: Bearer <token>.That's the whole triangle. Everything on this page is the middle bullet.
The SDK has no opinion about what a valid token looks like. You tell it, by implementing TokenVerifier:
--8<-- "docs_src/authorization/tutorial001.py"
TokenVerifier is a protocol with one async method. verify_token gets the raw token from the Authorization header and returns an AccessToken if it's valid, None if it isn't. There is nothing else to implement.token_verifier= and auth= always travel together. Pass one without the other and MCPServer(...) raises a ValueError before it ever serves a request.AuthSettings is the public face of your resource server:
issuer_url: the authorization server that issues your tokens.resource_server_url: the public URL of this MCP endpoint. It names which resource a token is for, and it's where the discovery document lives.required_scopes: every token must carry all of them.!!! tip
examples/servers/simple-auth/ in the SDK repository has an IntrospectionTokenVerifier that calls
a real authorization server's RFC 7662 endpoint. It's the shape most production verifiers take.
Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: mcp.run(transport="streamable-http") puts it on http://127.0.0.1:8000/mcp, and Running your server has the rest. The app now has two routes:
/mcp
/.well-known/oauth-protected-resource/mcp
You registered one tool. The second route is the SDK's.
GET that well-known path and you get RFC 9728 Protected Resource Metadata, built straight from your AuthSettings:
{
"resource": "http://127.0.0.1:8000/mcp",
"authorization_servers": ["https://auth.example.com/"],
"scopes_supported": ["notes:read"],
"bearer_methods_supported": ["header"]
}
This document is how a client that has never heard of your server finds its way in: it reads authorization_servers and goes there for a token. You wrote none of it.
!!! check
Call /mcp with no token (or with one your verifier returned None for) and the request is
stopped at the door:
```text
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
{"error": "invalid_token", "error_description": "Authentication required"}
```
Nothing was parsed and no tool ran. And that `resource_metadata` pointer in `WWW-Authenticate` is
what makes discovery automatic: 401 -> metadata document -> authorization server -> token -> retry.
!!! warning
None of this protects stdio. A pipe has no Authorization header, so token_verifier is never
consulted there. A stdio server's security boundary is the process that launched it. The same
goes for the in-memory Client(mcp) you use in tests: it connects straight to the server object
and skips the HTTP layer, authorization included.
Inside any handler, get_access_token() is the AccessToken your verifier returned for the current request:
--8<-- "docs_src/authorization/tutorial002.py"
client_id, scopes, subject, expires_at, and any extra claims you attached. That's the hook for per-tool rules: read the scopes and refuse.None. In-memory and over stdio it is always None.Call whoami with Authorization: Bearer alice-token and the model reads:
alice (scopes: notes:read)
The SDK gives you the resource-server half: verify, advertise, refuse. It does not give you a login page, a consent screen, or a token.
To watch all three parties move, run examples/servers/simple-auth/ from the SDK repository (a small authorization server and a resource server set up exactly like this page) and then point examples/clients/simple-auth-client/ at it for the full discovery-and-token dance.
!!! info
There is a second constructor argument, auth_server_provider=, that embeds a full authorization
server inside your MCP server. It predates the AS/RS separation that the MCP authorization spec
is built around. New servers should not reach for it.
An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is Identity assertion.
TokenVerifier is the whole integration surface: one async method, token in, AccessToken | None out.token_verifier= and auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...]) always travel together./.well-known/oauth-protected-resource/... and answers unauthenticated requests with a 401 whose WWW-Authenticate header points at it. That is the entire discovery story.get_access_token() in any handler is who's calling.stdio and the in-memory client never see it.The other side of the handshake, a client that discovers your authorization server and fetches the token for you, is OAuth clients.