docs: convert bold cross-references into links, link SEP and RFC mentions (#3017)
This commit is contained in:
parent
f2e63c979a
commit
24fdd909ac
12 changed files with 167 additions and 167 deletions
|
|
@ -32,11 +32,11 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl
|
|||
|
||||
!!! 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.
|
||||
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** has the rest. The app now has two routes:
|
||||
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](../run/index.md)** has the rest. The app now has two routes:
|
||||
|
||||
```text
|
||||
/mcp
|
||||
|
|
@ -47,7 +47,7 @@ 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`:
|
||||
`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
|
||||
{
|
||||
|
|
@ -109,15 +109,15 @@ To watch all three parties move, run `examples/servers/simple-auth/` from the SD
|
|||
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**.
|
||||
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](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 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.
|
||||
* 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 other side of the handshake, a client that discovers your authorization server and fetches the token for you, is **OAuth clients**.
|
||||
The other side of the handshake, a client that discovers your authorization server and fetches the token for you, is **[OAuth clients](oauth-clients.md)**.
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@ The table below names each deprecated feature, why it is going away, and the rep
|
|||
|
||||
| Deprecated | Why | What you do instead |
|
||||
|---|---|---|
|
||||
| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **Multi-round-trip requests**). |
|
||||
| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **Multi-round-trip requests**). |
|
||||
| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **Logging**). |
|
||||
| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **[Multi-round-trip requests](multi-round-trip.md)**). |
|
||||
| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **[Multi-round-trip requests](multi-round-trip.md)**). |
|
||||
| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **[Logging](../tutorial/logging.md)**). |
|
||||
| **`ping`**: `client.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. It only works against a `mode="legacy"` connection. |
|
||||
| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **Progress**). |
|
||||
| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **[Progress](../tutorial/progress.md)**). |
|
||||
|
||||
Three things fall out of that table:
|
||||
|
||||
* Roots, sampling, and logging go together. One proposal, **SEP-2577**, deprecates all three capabilities at once.
|
||||
* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **Multi-round-trip requests**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks.
|
||||
* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **[Multi-round-trip requests](multi-round-trip.md)**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks.
|
||||
* `ping` is the odd one out. The protocol does not deprecate it, it removes it. The SDK method still warns (its message says *removed*, not *deprecated*) and calling it on a modern connection answers with *"Method not found"*.
|
||||
|
||||
## Deprecated is advisory
|
||||
|
|
@ -81,8 +81,8 @@ That is the whole API. There is no per-method switch, and you don't want one: th
|
|||
|
||||
## Recap
|
||||
|
||||
* The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all SEP-2577), restricts **progress** to server-to-client, and removes **`ping`**.
|
||||
* The replacement column points you onward: **Multi-round-trip requests** for sampling and roots, **Logging** for logging, **Progress** for progress. `ping` needs nothing at all.
|
||||
* The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), restricts **progress** to server-to-client, and removes **`ping`**.
|
||||
* The replacement column points you onward: **[Multi-round-trip requests](multi-round-trip.md)** for sampling and roots, **[Logging](../tutorial/logging.md)** for logging, **[Progress](../tutorial/progress.md)** for progress. `ping` needs nothing at all.
|
||||
* Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default).
|
||||
* Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise.
|
||||
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ An **extension** is an opt-in bundle of MCP behaviour behind one identifier.
|
|||
|
||||
It can contribute tools, resources, and new request methods, and it can wrap `tools/call`.
|
||||
The server advertises it under `capabilities.extensions`, the client opts in the same way,
|
||||
and nothing changes for anyone who didn't ask for it. That is the contract (SEP-2133), and
|
||||
and nothing changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and
|
||||
it has one golden rule: **extensions are off by default**.
|
||||
|
||||
## Using an extension
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
# Identity assertion
|
||||
|
||||
Every provider in **OAuth clients** starts by asking the MCP server a question: *which authorization server do you trust?* It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one.
|
||||
Every provider in **[OAuth clients](oauth-clients.md)** starts by asking the MCP server a question: *which authorization server do you trust?* It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one.
|
||||
|
||||
An enterprise wants neither decided per server. It already runs an identity provider (Okta, Microsoft Entra ID, your own); the user already signed in to it this morning; and it is the one place the security team wants to decide who may reach what. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), the **Enterprise-Managed Authorization** extension, moves the decision there. The IdP signs a short-lived JWT, an **Identity Assertion JWT Authorization Grant**, the **ID-JAG**: a statement that *this user*, through *this client*, may reach *this MCP server*. The client trades it for an ordinary access token. No browser, no consent screen, no dynamic registration.
|
||||
|
||||
This chapter is both ends of that trade. The MCP server itself never changes: it is still the resource server from **Authorization**, checking whatever token shows up.
|
||||
This chapter is both ends of that trade. The MCP server itself never changes: it is still the resource server from **[Authorization](authorization.md)**, checking whatever token shows up.
|
||||
|
||||
## Two token requests
|
||||
|
||||
Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **Authorization**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In the flows you already know, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first.
|
||||
Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **[Authorization](authorization.md)**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In the flows you already know, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first.
|
||||
|
||||
The client makes one token request to each.
|
||||
|
||||
1. **To the enterprise IdP.** The client trades the user's sign-in (their OpenID Connect ID token) for the ID-JAG. This is an RFC 8693 token exchange, it is entirely your IdP's API, and **the SDK does not make it**. You do, inside one async callback. It is also where the policy decision happens: an IdP that says no never issues the ID-JAG, and there is nothing to present.
|
||||
2. **To the MCP authorization server.** The client presents the ID-JAG under the RFC 7523 `jwt-bearer` grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`) and receives the access token. **This is the request the SDK makes**, and accepting it is the one thing this page adds to an authorization server.
|
||||
1. **To the enterprise IdP.** The client trades the user's sign-in (their OpenID Connect ID token) for the ID-JAG. This is an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange, it is entirely your IdP's API, and **the SDK does not make it**. You do, inside one async callback. It is also where the policy decision happens: an IdP that says no never issues the ID-JAG, and there is nothing to present.
|
||||
2. **To the MCP authorization server.** The client presents the ID-JAG under the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`) and receives the access token. **This is the request the SDK makes**, and accepting it is the one thing this page adds to an authorization server.
|
||||
|
||||
Everything below is the second request: the client that sends it and the authorization server that answers it.
|
||||
|
||||
## The client
|
||||
|
||||
**`IdentityAssertionOAuthProvider`** lives in `mcp.client.auth.extensions.identity_assertion`. Like every provider in **OAuth clients** it is an `httpx.Auth`: construct one, put it on `auth=`, hand the `httpx.AsyncClient` to the transport.
|
||||
**`IdentityAssertionOAuthProvider`** lives in `mcp.client.auth.extensions.identity_assertion`. Like every provider in **[OAuth clients](oauth-clients.md)** it is an `httpx.Auth`: construct one, put it on `auth=`, hand the `httpx.AsyncClient` to the transport.
|
||||
|
||||
```python title="client.py" hl_lines="49-50 53-61"
|
||||
--8<-- "docs_src/identity_assertion/tutorial001.py"
|
||||
|
|
@ -27,7 +27,7 @@ Everything below is the second request: the client that sends it and the authori
|
|||
|
||||
Read it from the bottom.
|
||||
|
||||
* `main()` is the `main()` from **OAuth clients**, line for line. That is the point: once the provider exists, nothing downstream knows which grant produced the token.
|
||||
* `main()` is the `main()` from **[OAuth clients](oauth-clients.md)**, line for line. That is the point: once the provider exists, nothing downstream knows which grant produced the token.
|
||||
* The provider takes what the other providers cannot discover: a `client_id` and `client_secret` somebody **pre-registered** with the authorization server, that authorization server's `issuer`, and `assertion_provider`, an async callback that returns a fresh ID-JAG on demand.
|
||||
* `storage` is the same `TokenStorage` protocol. Only the two token methods are ever called; there is no dynamic registration here, so there is no `client_info` to remember.
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ Read it from the bottom.
|
|||
|
||||
`fetch_id_jag(audience, resource)` is the only code you write. It is awaited once per token exchange, never at construction, and only *after* the authorization server's metadata has been fetched and validated, so a misconfigured issuer never leaks an assertion. Its two arguments are two of the claims the ID-JAG must be minted with: `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's canonical identifier (the ID-JAG `resource`). The third is one you already hold: the ID-JAG's `client_id` claim must name the `client_id` you gave the provider, or the authorization server refuses the exchange.
|
||||
|
||||
`idp_issue_id_jag` above it is **not your code**. It stands in for the identity provider, signing the assertion in-process so the file is complete and you can read every claim an ID-JAG carries. A real `fetch_id_jag` makes the first token request of the previous section instead: an RFC 8693 token exchange against your IdP, defined by the Identity Assertion JWT Authorization Grant draft that SEP-990 profiles. The signed-in user's ID token goes in as the `subject_token`, the `requested_token_type` is the ID-JAG's own URN (`urn:ietf:params:oauth:token-type:id-jag`), `audience` and `resource` pass straight through, and the response carries the ID-JAG. That exchange, under those names, is what to look for in your IdP's documentation.
|
||||
`idp_issue_id_jag` above it is **not your code**. It stands in for the identity provider, signing the assertion in-process so the file is complete and you can read every claim an ID-JAG carries. A real `fetch_id_jag` makes the first token request of the previous section instead: an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange against your IdP, defined by the Identity Assertion JWT Authorization Grant draft that [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) profiles. The signed-in user's ID token goes in as the `subject_token`, the `requested_token_type` is the ID-JAG's own URN (`urn:ietf:params:oauth:token-type:id-jag`), `audience` and `resource` pass straight through, and the response carries the ID-JAG. That exchange, under those names, is what to look for in your IdP's documentation.
|
||||
|
||||
!!! tip
|
||||
A fresh ID-JAG is requested for every exchange, and that is the point: it is a single-use,
|
||||
|
|
@ -44,7 +44,7 @@ Read it from the bottom.
|
|||
|
||||
### The issuer is configuration
|
||||
|
||||
Here is the inversion. `OAuthClientProvider` asks the resource server which authorization server to use and follows the answer wherever it points. This provider refuses to: `issuer` is required, the RFC 8414 metadata is fetched from that issuer's own well-known path, the token endpoint must be on that issuer's origin, and the resource server is never asked anything.
|
||||
Here is the inversion. `OAuthClientProvider` asks the resource server which authorization server to use and follows the answer wherever it points. This provider refuses to: `issuer` is required, the [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) metadata is fetched from that issuer's own well-known path, the token endpoint must be on that issuer's origin, and the resource server is never asked anything.
|
||||
|
||||
The extension does not demand this; it is a deliberately stricter choice. This client carries two things worth stealing, a pre-registered secret and an audience-bound assertion, and a client that let a compromised MCP server steer it to an attacker's authorization server would post both to it. Pinning the issuer at construction deletes that conversation.
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ The extension does not demand this; it is a deliberately stricter choice. This c
|
|||
|
||||
### A confidential client
|
||||
|
||||
`client_secret` is required; the constructor raises `ValueError` without one. The IETF profile underneath SEP-990 reserves this grant for confidential clients, SEP-990 requires the client to authenticate, and this SDK enforces both by insisting on a shared secret. `token_endpoint_auth_method` picks where it travels: `client_secret_post` (the default, in the form body) or `client_secret_basic` (an HTTP Basic header). The profile also permits `private_key_jwt`; this provider does not support it.
|
||||
`client_secret` is required; the constructor raises `ValueError` without one. The IETF profile underneath [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) reserves this grant for confidential clients, SEP-990 requires the client to authenticate, and this SDK enforces both by insisting on a shared secret. `token_endpoint_auth_method` picks where it travels: `client_secret_post` (the default, in the form body) or `client_secret_basic` (an HTTP Basic header). The profile also permits `private_key_jwt`; this provider does not support it.
|
||||
|
||||
!!! tip
|
||||
Read `client_secret` from the environment or a secret manager, never from source control.
|
||||
|
|
@ -68,15 +68,15 @@ The extension does not demand this; it is a deliberately stricter choice. This c
|
|||
|
||||
The first request goes out unauthenticated, and the server's `401` starts the flow.
|
||||
|
||||
1. **Discovery.** It fetches the authorization server metadata from the configured issuer's RFC 8414 well-known path, checks the document's `issuer` matches, and checks the token endpoint is on the issuer's origin.
|
||||
1. **Discovery.** It fetches the authorization server metadata from the configured issuer's [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known path, checks the document's `issuer` matches, and checks the token endpoint is on the issuer's origin.
|
||||
2. **The assertion.** It awaits your `assertion_provider`.
|
||||
3. **Exchange.** It POSTs the `jwt-bearer` grant to the token endpoint, stores the `OAuthToken`, and replays your original request with `Authorization: Bearer ...`.
|
||||
|
||||
A `403` whose `WWW-Authenticate` names `insufficient_scope` runs steps 2 and 3 again with the union of your `scope` and the challenged one. (`scope` is only ever a request; this page's authorization server grants what the ID-JAG says and nothing else.) There is no refresh token anywhere in this: when the access token expires, the next `401` mints a fresh ID-JAG and exchanges again, and *that* is the lever the IdP holds. Failures are the same two exceptions as the rest of **OAuth clients**: `OAuthFlowError` for discovery and validation, its subclass `OAuthTokenError` when the token endpoint says no.
|
||||
A `403` whose `WWW-Authenticate` names `insufficient_scope` runs steps 2 and 3 again with the union of your `scope` and the challenged one. (`scope` is only ever a request; this page's authorization server grants what the ID-JAG says and nothing else.) There is no refresh token anywhere in this: when the access token expires, the next `401` mints a fresh ID-JAG and exchanges again, and *that* is the lever the IdP holds. Failures are the same two exceptions as the rest of **[OAuth clients](oauth-clients.md)**: `OAuthFlowError` for discovery and validation, its subclass `OAuthTokenError` when the token endpoint says no.
|
||||
|
||||
## The authorization server
|
||||
|
||||
Most of the time you stop here. The MCP authorization server is somebody else's product, accepting ID-JAGs is its configuration to turn on, and the SDK's half of SEP-990 is the client above.
|
||||
Most of the time you stop here. The MCP authorization server is somebody else's product, accepting ID-JAGs is its configuration to turn on, and the SDK's half of [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) is the client above.
|
||||
|
||||
The SDK can also *be* the authorization server: `create_auth_routes` returns the authorization server's routes as a list any Starlette app can mount, which is how `examples/servers/simple-auth/` in the repository runs one. SEP-990 adds one flag and one method to that surface:
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ The SDK can also *be* the authorization server: `create_auth_routes` returns the
|
|||
The SDK never decodes the assertion: only your deployment knows which IdP it trusts and which
|
||||
keys that IdP publishes, so everything inside `exchange_identity_assertion` is load-bearing.
|
||||
Verify the signature against the IdP's published keys (its JWKS; the shared secret here is the
|
||||
demo's), and `iss` and `exp`, per RFC 7523 §3. Require the JWT header's `typ` to be
|
||||
demo's), and `iss` and `exp`, per [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Require the JWT header's `typ` to be
|
||||
`oauth-id-jag+jwt`, the profile's guard against some other JWT being replayed as a grant.
|
||||
Require `aud` to be your own issuer. Require the ID-JAG's `client_id` claim to equal the client
|
||||
the handler authenticated, and its `resource` claim to name a resource you actually serve.
|
||||
|
|
@ -108,7 +108,7 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I
|
|||
|
||||
!!! info
|
||||
A server that still embeds its authorization server with `auth_server_provider=` reaches the same
|
||||
code through `AuthSettings(identity_assertion_enabled=True)`. **Authorization** explains why new
|
||||
code through `AuthSettings(identity_assertion_enabled=True)`. **[Authorization](authorization.md)** explains why new
|
||||
servers should not start there.
|
||||
|
||||
!!! check
|
||||
|
|
@ -137,10 +137,10 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I
|
|||
|
||||
## Recap
|
||||
|
||||
* SEP-990 lets the enterprise identity provider, not the end user, decide which MCP servers a client may reach. The IdP signs that decision into an **ID-JAG**.
|
||||
* Obtaining the ID-JAG is an RFC 8693 token exchange against *your IdP*, and the SDK does not make it. Presenting it to the MCP authorization server is the RFC 7523 `jwt-bearer` grant, and the SDK does both sides of that.
|
||||
* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) lets the enterprise identity provider, not the end user, decide which MCP servers a client may reach. The IdP signs that decision into an **ID-JAG**.
|
||||
* Obtaining the ID-JAG is an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange against *your IdP*, and the SDK does not make it. Presenting it to the MCP authorization server is the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant, and the SDK does both sides of that.
|
||||
* `IdentityAssertionOAuthProvider` is another `httpx.Auth`: a pre-registered confidential client, a pinned `issuer`, and one `assertion_provider(audience, resource)` callback. No browser, no registration, no refresh token.
|
||||
* The authorization server is never discovered from the resource server. Configure `issuer` to exactly the string its metadata document serves; the comparison is character for character.
|
||||
* Server side, `identity_assertion_enabled=True` plus `exchange_identity_assertion`. The SDK authenticates the client and gates the grant; validating the ID-JAG is entirely yours, and the issued token is bound to the ID-JAG's `resource`, not the request's.
|
||||
|
||||
The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in **Authorization**.
|
||||
The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in **[Authorization](authorization.md)**.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ For everything else, stay on `MCPServer`.
|
|||
|
||||
## The same tool, by hand
|
||||
|
||||
This is `search_books` from **Tools** (the nine-line `@mcp.tool()` file) with the sugar removed:
|
||||
This is `search_books` from **[Tools](../tutorial/tools.md)** (the nine-line `@mcp.tool()` file) with the sugar removed:
|
||||
|
||||
```python title="server.py" hl_lines="23 27 33"
|
||||
--8<-- "docs_src/lowlevel/tutorial001.py"
|
||||
|
|
@ -61,7 +61,7 @@ The same text the `@mcp.tool()` version produced. Two honest differences:
|
|||
|
||||
## Nothing is checked for you
|
||||
|
||||
In **Tools** you saw a bad argument get rejected before your function ran. That was `MCPServer` validating the call against the schema it generated.
|
||||
In **[Tools](../tutorial/tools.md)** you saw a bad argument get rejected before your function ran. That was `MCPServer` validating the call against the schema it generated.
|
||||
|
||||
`Server` does not do that. Your `input_schema` is *advertised* to the client; it is never *applied* to `params.arguments`.
|
||||
|
||||
|
|
@ -72,9 +72,9 @@ In **Tools** you saw a bad argument get rejected before your function ran. That
|
|||
MCPError: Internal server error
|
||||
```
|
||||
|
||||
A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **Testing**.)
|
||||
A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **[Testing](../tutorial/testing.md)**.)
|
||||
|
||||
That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **Handling errors**.
|
||||
That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **[Handling errors](../tutorial/handling-errors.md)**.
|
||||
|
||||
## Two tools, one handler
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ Call it and the result carries both representations:
|
|||
}
|
||||
```
|
||||
|
||||
The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **Structured Output**.
|
||||
The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../tutorial/structured-output.md)**.
|
||||
|
||||
## `_meta`: for the application, not the model
|
||||
|
||||
|
|
@ -147,7 +147,7 @@ No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_promp
|
|||
|
||||
* The lifespan is a `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` on an `async` generator gives you exactly that.
|
||||
* Whatever it `yield`s becomes `ctx.lifespan_context`, and because the handlers are annotated `ServerRequestContext[Catalog]`, `.search(...)` autocompletes and type-checks.
|
||||
* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **Lifespan**.
|
||||
* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **[Lifespan](../tutorial/lifespan.md)**.
|
||||
|
||||
Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`.
|
||||
|
||||
|
|
@ -175,15 +175,15 @@ use Server.middleware to observe or wrap initialization
|
|||
The handshake belongs to the runner. `server/discover`, `ping`, and every other built-in are yours to replace.
|
||||
|
||||
!!! tip
|
||||
`Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **Middleware**.
|
||||
`Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **[Middleware](middleware.md)**.
|
||||
|
||||
## The other handlers
|
||||
|
||||
Each of these is one idea you now have the vocabulary for; each has its own chapter.
|
||||
|
||||
* `on_call_tool` may return an `InputRequiredResult` instead of a `CallToolResult` to pause the call and ask the client for input; see **Multi-round-trip requests**.
|
||||
* `on_call_tool` may return an `InputRequiredResult` instead of a `CallToolResult` to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**.
|
||||
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
|
||||
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **Running your server** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.
|
||||
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.
|
||||
|
||||
## Recap
|
||||
|
||||
|
|
@ -195,4 +195,4 @@ Each of these is one idea you now have the vocabulary for; each has its own chap
|
|||
* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved.
|
||||
* The capabilities a `Server` advertises are derived from which handlers you registered.
|
||||
|
||||
`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **Middleware**.
|
||||
`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ You write it as `async (ctx, call_next)` and append it to `server.middleware`. T
|
|||
Do not make it the foundation your server stands on.
|
||||
|
||||
This is a **low-level `Server`** feature. `MCPServer` does not expose a middleware list.
|
||||
If `Server(name, on_call_tool=...)` is new to you, read **The low-level Server** first.
|
||||
If `Server(name, on_call_tool=...)` is new to you, read **[The low-level Server](low-level-server.md)** first.
|
||||
|
||||
## A timing middleware
|
||||
|
||||
|
|
@ -84,7 +84,7 @@ In increasing order of how much you should hesitate:
|
|||
The SDK ships exactly one middleware, and it is already on your server's list: the one that
|
||||
emits an OpenTelemetry span for every message. You don't append it, and most of the time you
|
||||
don't think about it. It is a no-op until you install an exporter, and it has its own page:
|
||||
**OpenTelemetry**.
|
||||
**[OpenTelemetry](opentelemetry.md)**.
|
||||
|
||||
!!! info
|
||||
If you have written ASGI middleware, you already know this shape. Starlette's
|
||||
|
|
@ -101,8 +101,8 @@ don't think about it. It is a no-op until you install an exporter, and it has it
|
|||
* `ctx.request_id is None` is how you tell a notification from a request.
|
||||
* Raise instead of calling `call_next` to refuse one message; the connection survives.
|
||||
* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See
|
||||
**OpenTelemetry**.
|
||||
**[OpenTelemetry](opentelemetry.md)**.
|
||||
* The whole surface is provisional. Observe with it; don't build on it.
|
||||
|
||||
That is everything that wraps a request. **Authorization** is what decides whether the request
|
||||
That is everything that wraps a request. **[Authorization](authorization.md)** is what decides whether the request
|
||||
gets to run at all.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ The high-level `@mcp.tool()` decorator has no sugar for this yet. Today you writ
|
|||
* On the first call `params.input_responses` is `None`, so the guard fires and the handler asks instead of answering.
|
||||
* On the retry, the `ElicitResult` the client sent is sitting under the **same key** (`"region"`) that the server used in `input_requests`.
|
||||
|
||||
Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **The low-level Server**. This page only adds the second return type.
|
||||
Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **[The low-level Server](low-level-server.md)**. This page only adds the second return type.
|
||||
|
||||
## The client side
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ Drop to the underlying session, where `allow_input_required=True` hands you the
|
|||
**URL-mode elicitation** rides this exact mechanism on a 2026 connection. The entry in
|
||||
`input_requests` is an `ElicitRequest` whose params are `ElicitRequestURLParams`; the user
|
||||
finishes the out-of-band flow and your client retries the call. Same loop, no new API. The
|
||||
high-level server half is in **Elicitation**.
|
||||
high-level server half is in **[Elicitation](../tutorial/elicitation.md)**.
|
||||
|
||||
## Recap
|
||||
|
||||
|
|
@ -95,4 +95,4 @@ Drop to the underlying session, where `allow_input_required=True` hands you the
|
|||
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
|
||||
* The server side is the **low-level** `Server` only; `@mcp.tool()` has no sugar for this yet.
|
||||
|
||||
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **Deprecated features**.
|
||||
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Some MCP servers are protected. Send them a request without a token and they ans
|
|||
|
||||
**`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx.Auth`, the standard httpx hook for "do something to every request". You attach it to an `httpx.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it.
|
||||
|
||||
This chapter is the client side. Making your own server demand a token is **Authorization**.
|
||||
This chapter is the client side. Making your own server demand a token is **[Authorization](authorization.md)**.
|
||||
|
||||
## The provider
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ Nothing else in the file mentions OAuth. `main()` never sees a token.
|
|||
|
||||
### Client metadata
|
||||
|
||||
`OAuthClientMetadata` is the real RFC 7591 registration document, as a Pydantic model.
|
||||
`OAuthClientMetadata` is the real [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) registration document, as a Pydantic model.
|
||||
|
||||
You set three fields. The defaults fill in the rest: `grant_types` is already `["authorization_code", "refresh_token"]` and `response_types` is already `["code"]`, which is exactly the flow this provider runs.
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ A real client runs a small local HTTP server on the redirect URI instead of call
|
|||
|
||||
Look at `main()`. The provider goes on the **httpx client**, the httpx client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`.
|
||||
|
||||
`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **Client transports**.
|
||||
`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **[Client transports](../client/transports.md)**.
|
||||
|
||||
## What the provider does for you
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ By default the secret travels as HTTP Basic auth on the token request (`client_s
|
|||
the same pattern: construct one, put it on `auth=`. The same module ships
|
||||
`SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion.
|
||||
|
||||
There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own chapter, **Identity assertion**.
|
||||
There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own chapter, **[Identity assertion](identity-assertion.md)**.
|
||||
|
||||
## When it fails
|
||||
|
||||
|
|
@ -136,4 +136,4 @@ Not everything is a flow error. The network can still fail; those are ordinary `
|
|||
* `ClientCredentialsOAuthProvider` is the no-human version: `client_id` + `client_secret`, no handlers, no browser.
|
||||
* Every OAuth failure is an `OAuthFlowError`; `OAuthRegistrationError` and `OAuthTokenError` are its subclasses.
|
||||
|
||||
The other half of this handshake, making your *server* demand the token, is **Authorization**.
|
||||
The other half of this handshake, making your *server* demand the token, is **[Authorization](authorization.md)**.
|
||||
|
|
|
|||
|
|
@ -104,4 +104,4 @@ mcp._lowlevel_server.middleware[:] = [
|
|||
with no change to your server.
|
||||
* Client-to-server trace context propagates automatically when both sides run the SDK.
|
||||
|
||||
Next, the thing that decides whether a request runs at all: **Authorization**.
|
||||
Next, the thing that decides whether a request runs at all: **[Authorization](authorization.md)**.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Most servers never need this.
|
|||
|
||||
Pagination is for the server whose resource list is really a database: thousands of rows it refuses to serialize in one response. The protocol's answer is a **cursor**: the server returns a page plus an opaque token, and the client sends that token back to get the next page.
|
||||
|
||||
`@mcp.resource()` has no hook for any of that. To page, you write the list handler yourself, on the **low-level Server**.
|
||||
`@mcp.resource()` has no hook for any of that. To page, you write the list handler yourself, on the **[low-level Server](low-level-server.md)**.
|
||||
|
||||
## A server that pages
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resourc
|
|||
|
||||
Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages.
|
||||
|
||||
This is the same loop **The Client** chapter showed you, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once.
|
||||
This is the same loop **[The Client](../client/index.md)** chapter showed you, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once.
|
||||
|
||||
## The three rules
|
||||
|
||||
|
|
@ -77,4 +77,4 @@ This is the same loop **The Client** chapter showed you, and it costs nothing ag
|
|||
* The client loop: pass `cursor=`, accumulate, repeat until `next_cursor is None`.
|
||||
* Cursors are opaque, the server owns the page size, and a non-paging client still gets page one.
|
||||
|
||||
The rest of the hand-written `Server` API (`on_call_tool`, `input_schema` dicts, `_meta`) is **The low-level Server**.
|
||||
The rest of the hand-written `Server` API (`on_call_tool`, `input_schema` dicts, `_meta`) is **[The low-level Server](low-level-server.md)**.
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ If you already hold a connected `ClientSession` (`Client.session` is one), hand
|
|||
|
||||
## The classic handshake
|
||||
|
||||
`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **Protocol versions**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better.
|
||||
`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **[Protocol versions](../client/protocol-versions.md)**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better.
|
||||
|
||||
## Recap
|
||||
|
||||
|
|
@ -79,4 +79,4 @@ If you already hold a connected `ClientSession` (`Client.session` is one), hand
|
|||
* `component_name_hook=` rewrites every registered name. The dict key changes, the wire name does not.
|
||||
* `connect_with_session` adds a session you already hold; `disconnect_from_server` removes one.
|
||||
|
||||
The handshake a group speaks (and the faster one a `Client` prefers) is the subject of **Protocol versions**.
|
||||
The handshake a group speaks (and the faster one a `Client` prefers) is the subject of **[Protocol versions](../client/protocol-versions.md)**.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ This is the reference for the URI-template syntax that
|
|||
[`@mcp.resource`](../tutorial/resources.md) accepts, and for the
|
||||
path-safety policy the SDK applies to extracted values. For an
|
||||
introduction to what resources are and when to use them, start with
|
||||
**Resources**; this page assumes you're already comfortable declaring a
|
||||
**[Resources](../tutorial/resources.md)**; this page assumes you're already comfortable declaring a
|
||||
resource and want the full operator set, the security knobs, or the
|
||||
low-level wiring.
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ details (message formats, lifecycle, pagination) see the
|
|||
|
||||
## The full operator set
|
||||
|
||||
**Resources** showed one placeholder, `{user_id}`. There are four more
|
||||
**[Resources](../tutorial/resources.md)** showed one placeholder, `{user_id}`. There are four more
|
||||
operator forms; here they are on one server so you can see them next to
|
||||
each other:
|
||||
|
||||
|
|
@ -201,13 +201,13 @@ These checks are a heuristic pre-filter; for filesystem access,
|
|||
!!! tip
|
||||
If your handler can't fulfil the request (the file doesn't exist,
|
||||
the id is unknown), raise an exception. The SDK turns it into an
|
||||
error response. See **Handling errors** for the difference between a
|
||||
error response. See **[Handling errors](../tutorial/handling-errors.md)** for the difference between a
|
||||
protocol error and a tool error.
|
||||
|
||||
## Resources on the low-level Server
|
||||
|
||||
If you're building on the low-level `Server` (see **The low-level
|
||||
Server**), you register handlers for the `resources/list` and
|
||||
If you're building on the low-level `Server` (see **[The low-level
|
||||
Server](low-level-server.md)**), you register handlers for the `resources/list` and
|
||||
`resources/read` protocol methods directly. There's no decorator; you
|
||||
return the protocol types yourself.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue