python-sdk/docs/advanced/authorization.md

121 lines
6.4 KiB
Markdown

# Authorization
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.
## The three parties
* The **authorization server** signs people in and issues access tokens. You don't write this. It's your identity provider (Auth0, Keycloak, Entra, your own).
* The **resource server** is your MCP server. It verifies the token on every request.
* The **client** discovers which authorization server you trust, gets a token from it, and sends it back to you as `Authorization: Bearer <token>`.
That's the whole triangle. Everything on this page is the middle bullet.
## A token verifier
The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**:
```python title="server.py" hl_lines="12-14 19-24"
--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.
* This one looks the token up in a table. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint. That code is yours; the SDK only calls it.
* `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.
## What you get over HTTP
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:
```text
/mcp
/.well-known/oauth-protected-resource/mcp
```
You registered one tool. The second route is the SDK's.
### Discovery
`GET` that well-known path and you get **RFC 9728 Protected Resource Metadata**, built straight from your `AuthSettings`:
```json
{
"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.
## The caller's identity
Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request:
```python title="server.py" hl_lines="4 32-35"
--8<-- "docs_src/authorization/tutorial002.py"
```
* It works in tools, resources, and prompts, and there is nothing to pass around: the auth middleware stores it in a context variable per request.
* You get back the **same object your verifier built**: `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.
* Outside an authenticated HTTP request it returns `None`. In-memory and over `stdio` it is always `None`.
Call `whoami` with `Authorization: Bearer alice-token` and the model reads:
```text
alice (scopes: notes:read)
```
## The half the SDK doesn't do
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.
## Recap
* Over Streamable HTTP your server is an OAuth 2.1 **resource server**: it verifies tokens, it never issues them.
* `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.
* The SDK publishes RFC 9728 Protected Resource Metadata at `/.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.
* Authorization is an HTTP concern. `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**.