49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
126 lines
7.2 KiB
Markdown
126 lines
7.2 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.
|
|
|
|
This page is the server side. A client that discovers your authorization server and fetches the token is **[OAuth clients](../client/oauth-clients.md)**.
|
|
|
|
## 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](https://datatracker.ietf.org/doc/html/rfc7662) 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](index.md)** 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](https://datatracker.ietf.org/doc/html/rfc9728) 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.
|
|
|
|
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](../client/identity-assertion.md)**.
|
|
|
|
## 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](https://datatracker.ietf.org/doc/html/rfc9728) 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 client half (discovering your authorization server and fetching the token for you) is **[OAuth clients](../client/oauth-clients.md)**. And a client that *asserts* an identity instead of asking a user for one is **[Identity assertion](../client/identity-assertion.md)**.
|