Rebuild the docs around tested examples; shrink README.v2.md to a pitch (#2978)
This commit is contained in:
parent
4caa41f6d5
commit
411a6d3980
1 changed files with 9510 additions and 2639 deletions
121
docs/advanced/authorization.md
Normal file
121
docs/advanced/authorization.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# 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**.
|
||||
Loading…
Reference in a new issue