chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# Caching hints
|
||||
|
||||
Every result a server returns for `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` and `server/discover` carries two fields on the 2026-07-28 protocol: `ttlMs`, how many milliseconds a client may treat the result as fresh, and `cacheScope`, whether a cached result may be shared across users (`"public"`) or belongs to one authorization context (`"private"`).
|
||||
|
||||
The server doesn't cache anything. The fields are a *declaration*: "this tool list is the same for everyone and won't change for a minute." A client (or a gateway in front of you) may then skip the round trip. Honoring the hints is the client's choice; emitting them is the server's job, and the SDK does it for you.
|
||||
|
||||
Out of the box every result says `ttlMs: 0, cacheScope: "private"`: immediately stale, never shared. That is always safe and always conformant. If your lists really are stable and identical for all callers, say so at construction:
|
||||
|
||||
```python title="server.py" hl_lines="5-8"
|
||||
--8<-- "docs_src/caching/tutorial001.py"
|
||||
```
|
||||
|
||||
* The map is keyed by **method name**, and the six cacheable methods are the only legal keys. The parameter is typed `Mapping[CacheableMethod, CacheHint]`, so your editor autocompletes the keys and flags a typo before you run; anything that slips past the type checker raises at construction.
|
||||
* A method you don't mention keeps the defaults. The map is a set of overrides, not a manifest.
|
||||
* `CacheHint(ttl_ms=5_000)` left `scope` unset, so it stays `"private"`: five seconds of freshness, per caller. Scope and TTL are independent decisions.
|
||||
* `"server/discover"` is a legal key too, since the discovery result is cacheable like any list.
|
||||
|
||||
!!! warning
|
||||
`cacheScope: "public"` means *anyone* may be served your cached response. A shared
|
||||
gateway will happily hand one user's result to another, even when the request was
|
||||
authenticated. Mark a result `"public"` only when it is identical for every caller, and
|
||||
never use `cacheScope` as access control: it is a label, not a lock.
|
||||
|
||||
## Per-handler override
|
||||
|
||||
On the low-level `Server`, handlers build their results by hand, and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field:
|
||||
|
||||
```python title="server.py" hl_lines="11 17"
|
||||
--8<-- "docs_src/caching/tutorial002.py"
|
||||
```
|
||||
|
||||
The handler said `ttl_ms=1_000` and nothing about scope. On the wire: `ttlMs: 1000` (the handler's, not the map's `60_000`) and `cacheScope: "public"` (the map's, because the handler left it unset). Explicit beats configured, and configured beats default. This holds per field, so a handler can pin one field and leave the other to the server-wide policy.
|
||||
|
||||
This is also the escape hatch for dynamics the constructor can't know: a handler that filters `resources/read` per user can return `cache_scope="private"` for one URI from an otherwise-public server.
|
||||
|
||||
One caveat on paginated lists: the protocol requires the **same `cacheScope` on every page** of one list. The constructor map satisfies that by construction, since it's keyed by method, not by page. But a handler that overrides the scope itself owns that consistency: override it on *every* page, never only when a cursor is present, or page one and page two will disagree.
|
||||
|
||||
## What the client sees
|
||||
|
||||
On a 2026-07-28 session, `Client` honors the hints for you: it has a built-in response cache, on by default. A result that arrives carrying a `ttlMs` is stored, and an identical call within that TTL is served from the cache with no round trip. A result that carries *no* hint is not cached: hint-less results get `CacheConfig.default_ttl_ms`, which defaults to `0` (immediately stale), so a server that declares nothing sees exactly the call-for-call traffic it always did.
|
||||
|
||||
```python title="client.py" hl_lines="34 36 39"
|
||||
--8<-- "docs_src/caching/tutorial003.py"
|
||||
```
|
||||
|
||||
Four calls, three fetches. The second call found a fresh entry and never reached the server; advancing the (injected) clock past the TTL made the third fetch again; the fourth said `cache_mode="refresh"`. That kwarg exists on the five caching verbs (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`):
|
||||
|
||||
* `"use"` (the default) serves a fresh entry if there is one, and stores the fetch if not.
|
||||
* `"refresh"` never serves: it fetches and stores the result, replacing whatever was cached.
|
||||
* `"bypass"` makes the round trip without touching the cache at all: no read, no write.
|
||||
|
||||
One rule sits above `"use"`: **calls carrying `meta` always reach the server.** A request with `meta` set (a progress token, tracing fields) expects a wire request, so under `cache_mode="use"` it is treated as `"refresh"`: the cache read is skipped, and the fetched result still replaces the cached entry. `"bypass"` and an explicit `"refresh"` behave as they always do.
|
||||
|
||||
To turn caching off entirely, construct with `Client(server, cache=False)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.
|
||||
|
||||
Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page.
|
||||
|
||||
One caveat on `resources/updated`: eviction is exact-URI only. The store contract has no enumerate or scan operation (same as the reference TypeScript implementation), so a notification carrying a *sub*-resource URI does not evict a cached read of its parent. If your server signals sub-resources this way, refetch the parent with `cache_mode="refresh"`.
|
||||
|
||||
### Configuring it: `CacheConfig`
|
||||
|
||||
```python
|
||||
from mcp.client import CacheConfig
|
||||
|
||||
client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000))
|
||||
```
|
||||
|
||||
* `store`: where entries live. The default is a fresh in-memory store per client; pass your own `ResponseCacheStore` implementation (Redis-backed, say) to share a cache across clients or processes. The contract types (`ResponseCacheStore`, `CacheKey`, `CacheEntry`, and the default `InMemoryResponseCacheStore`) are importable from `mcp.client`. A lookup may issue up to two sequential store `get`s (the private arm, then the public one), so size a remote store's latency expectations accordingly. A custom store **requires** an explicit `partition`.
|
||||
* `partition`: the authorization-context label that keeps one principal's `"private"` entries from being served to another within a shared store.
|
||||
* `target_id`: explicit server identity, for custom transports and in-process servers (below).
|
||||
* `default_ttl_ms`: TTL applied to results that carry no `ttlMs` hint. The default `0` leaves hint-less results uncached.
|
||||
* `share_public`: serve server-asserted-`"public"` entries across partitions (below). Off by default.
|
||||
* `clock`: the wall-clock source, in epoch seconds. Inject one, as the example above does, and expiry tests need no sleeping.
|
||||
|
||||
!!! warning "Partition = verified principal"
|
||||
Derive `partition` from a **verified credential**, such as a validated token's subject. Never derive it from request-supplied data, and never from the server URL (server identity is a separate key axis). The SDK is a library with no authentication of its own: the trust anchor is whoever constructs the `CacheConfig`, which is the deployment, not the tenant. A multi-tenant gateway mints one `CacheConfig` per authenticated principal.
|
||||
|
||||
The partition is also fixed for the `Client`'s lifetime. If the connection's authorization context changes mid-session (a re-authentication as a different principal, say), the cache does not follow; construct a new `Client` for the new principal.
|
||||
|
||||
Cache keys also carry the **server's identity**: the URL string you dialed, with any `user:pass@` userinfo stripped and otherwise byte-exact. No case folding, no query reordering, no trailing-slash cleanup. Under-normalizing only costs sharing, while over-normalizing could merge two tenants (`?tenant=a` vs `?tenant=b`), so superficially different URLs simply don't share entries. When there is no URL (an in-process server, or a `Transport` instance), the client gets a random per-instance identity instead; set `CacheConfig.target_id` to name the server (with a custom store this is required, and construction says so). The identity is sha256-hashed before it enters key material, so a URL carrying secrets in its query string never appears in store keys. Don't log the pre-hash form yourself, either.
|
||||
|
||||
!!! warning "`share_public` trusts the server, fleet-wide"
|
||||
By default even `"public"` entries stay within their partition. `share_public=True` serves entries the server marked `cacheScope: "public"` to **every** partition using the store, trusting the server's classification on behalf of all of them. A server that stamps `"public"` on per-tenant data (by bug or by malice) then leaks one tenant's response to the others. The flag is deliberately constructor-level only: the per-call `cache_mode` can narrow caching, but nothing per-call can widen sharing.
|
||||
|
||||
### What the cache never does
|
||||
|
||||
* **Session-tier calls bypass it.** `client.session.list_tools()` and friends always make the round trip; the cache lives on the `Client` verbs.
|
||||
* **`server/discover` stays out of it.** The discover result is delivered once, at connect, and never enters the response cache, even when it carries a `ttlMs`. If you persist one yourself to skip the reconnect probe ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), its freshness is your bookkeeping: `DiscoverResult` carries `ttl_ms` and `cache_scope`, already parsed, for exactly that purpose.
|
||||
* **Continuation pages are never cached.** Only cursor-less calls participate. A continuation page rejected for an expired cursor does *evict* the cached listing, because the listing changed under it.
|
||||
* **Multi-round-trip reads are never cached.** A `read_resource` seeded with `input_responses`/`request_state`, or one that resolves through input rounds, never enters the cache (a spec MUST).
|
||||
* **Notification eviction needs notifications.** Eviction is only as good as the transport's delivery, and the modern in-process path (`Client(server)` with the default `mode="auto"`) does not deliver standalone notifications today.
|
||||
* **Eviction is eventual, not instantaneous.** Wire-path notifications are dispatched from spawned tasks, so a call racing a notification's arrival may be served the pre-eviction entry once more; the window is bounded by dispatch latency, and the eviction still lands.
|
||||
* **No stale-if-error.** An expired entry is never served because the refetch failed; the error propagates.
|
||||
* **No early re-fetch.** A stored entry is served until its TTL expires and the next call after that pays the round trip; nothing refreshes in the background.
|
||||
* **No coalescing.** Two concurrent identical calls are two fetches.
|
||||
* **No TTL beyond 24 hours.** A larger `ttlMs`, whether server-sent or configured, is clamped down on store (`mcp.client.caching.MAX_TTL_MS`), bounding how long any entry, however generously hinted, can be served.
|
||||
* On a **shared store**, clients race each other. Each client drops its own write when an eviction overtook the fetch in flight, but a *co-tenant* client can still write back an entry that an eviction it never saw had removed; and that race bookkeeping is itself bounded: past 4096 tracked keys the oldest key's guard is dropped first. Both windows are accepted, and closed by the TTL cap above.
|
||||
* **No serving across protocol eras.** Entries are scoped to the negotiated protocol version: on a shared persistent store, a session never serves an entry written under a different negotiated version (the same listing genuinely differs by era, since the SDK strips the 2026 fields for older sessions). Eviction likewise touches only the current era's entries; another era's entries simply age out by TTL.
|
||||
|
||||
### Reading the hints yourself
|
||||
|
||||
The hints are also plain fields on every cacheable result (`result.ttl_ms` and `result.cache_scope`, already parsed), in case you want to layer your own bookkeeping on top of (or instead of) the built-in cache.
|
||||
|
||||
Against an **older server** (pre-2026 protocol), the fields are simply absent from the wire, and the models show their conservative defaults: `ttl_ms == 0` and `cache_scope == "private"`, stale and unshared, the right assumption for a server that declared nothing. The cache treats a legacy session the same way: hints are never consulted there (whatever keys appear on the wire), only `default_ttl_ms` applies, and its default of `0` caches nothing, so a pre-2026 connection behaves exactly as it did before the cache existed. If you need to distinguish "the server said 0" from "the server said nothing", check `"ttl_ms" in result.model_fields_set`: it's only set when the field actually arrived.
|
||||
|
||||
## Older clients
|
||||
|
||||
Clients on pre-2026 protocol versions never see either field; the SDK strips them at serialization for those connections. Configure your hints once; there is nothing version-specific to write.
|
||||
|
||||
## Recap
|
||||
|
||||
* Six methods carry `ttlMs`/`cacheScope`; the SDK defaults them to `0`/`"private"`, stale and unshared, always safe.
|
||||
* `cache_hints={method: CacheHint(...)}` at construction (both `MCPServer` and `Server`) sets server-wide values per method.
|
||||
* A handler that sets the fields on its result overrides the map, per field.
|
||||
* `"public"` is a promise that the result is identical for every caller. It is not access control.
|
||||
* `Client` honors the hints automatically: its response cache is on by default, serves fresh entries instead of refetching, and caches nothing for servers (or sessions) that provide no hints.
|
||||
* Per call, `cache_mode="refresh"` refetches and `"bypass"` skips the cache; `cache=False` at construction turns it off entirely.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Client callbacks
|
||||
|
||||
Nearly every request in MCP goes one way: client to server.
|
||||
|
||||
A server can also ask the **client** for things: to put a question to the user, to sample the user's model, to list the user's workspace folders. You answer those requests by passing **callbacks** to `Client(...)`.
|
||||
|
||||
## A server that asks
|
||||
|
||||
Here is a server whose tool can't finish on its own:
|
||||
|
||||
```python title="server.py" hl_lines="16"
|
||||
--8<-- "docs_src/client_callbacks/tutorial001.py"
|
||||
```
|
||||
|
||||
* `ctx.elicit(...)` sends an `elicitation/create` request **to the client** and waits.
|
||||
* The tool doesn't return until somebody (a person in a form, or your code) supplies a `name`.
|
||||
|
||||
That is the server half, and the **[Elicitation](../handlers/elicitation.md)** page owns it. This page is the other end of the wire.
|
||||
|
||||
## The elicitation callback
|
||||
|
||||
```python title="client.py" hl_lines="7-11 17-18"
|
||||
--8<-- "docs_src/client_callbacks/tutorial002.py"
|
||||
```
|
||||
|
||||
* An elicitation callback is `async (context, params) -> ElicitResult`.
|
||||
* `params.message` is the question. `params.requested_schema` is the JSON Schema of the answer the server wants. A real client renders a form from it; this one auto-fills.
|
||||
* You return `ElicitResult(action="accept", content={...})`, or `action="decline"`, or `action="cancel"`. The only other option is `ErrorData(...)`, which refuses the request and fails the whole call.
|
||||
* `context` is a `ClientRequestContext`: the live `session`, the server's `request_id`, and any `meta` it attached.
|
||||
|
||||
!!! tip
|
||||
`params` is a union of the two elicitation modes. Here `params.mode` is `"form"`; a `"url"` request
|
||||
carries `params.url` instead of a schema. One callback handles both; branch on `params.mode`.
|
||||
**[Elicitation](../handlers/elicitation.md)** shows the full pattern.
|
||||
|
||||
### Try it
|
||||
|
||||
Call `issue_card` and watch both ends.
|
||||
|
||||
Your callback receives the server's question, already parsed:
|
||||
|
||||
```python
|
||||
params.mode # 'form'
|
||||
params.message # 'What name should go on the card?'
|
||||
params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}},
|
||||
# 'required': ['name'], 'title': 'CardHolder', 'type': 'object'}
|
||||
```
|
||||
|
||||
It answers, `ctx.elicit(...)` resumes inside the tool, and the tool finishes:
|
||||
|
||||
```python
|
||||
result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
|
||||
```
|
||||
|
||||
One `tools/call` from you, one `elicitation/create` back from the server, answered by your function, all inside a single tool call.
|
||||
|
||||
!!! info
|
||||
`mode="legacy"` on line 17 is doing real work. By default `Client(...)` negotiates the modern
|
||||
protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit`
|
||||
fails before your callback ever runs. The transport doesn't decide that; the negotiated
|
||||
protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has
|
||||
to answer one; every test behind this page does. **[Protocol versions](../protocol-versions.md)** has the whole story.
|
||||
|
||||
On a 2026-07-28 session the callback isn't dead, it's fed differently: when a tool returns an
|
||||
`InputRequiredResult` carrying an `ElicitRequest`, `Client` dispatches that entry to the same
|
||||
`elicitation_callback` and retries the call for you. That flow is **[Multi-round-trip requests](../handlers/multi-round-trip.md)**.
|
||||
|
||||
## A callback is a capability
|
||||
|
||||
You never told the server that your client can answer elicitation requests. The SDK did.
|
||||
|
||||
When a client connects it declares its `capabilities`, the mirror image of the server's. You don't write that object. **Registering a callback is the declaration.**
|
||||
|
||||
| you pass | the client declares |
|
||||
| --- | --- |
|
||||
| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` |
|
||||
| `sampling_callback=` | `"sampling": {}` |
|
||||
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
|
||||
| none of them | `{}` |
|
||||
|
||||
Sampling sub-capabilities are the one refinement: pass `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` alongside `sampling_callback` when your sampler handles the `tools` / `tool_choice` parameters. Servers must see `sampling.tools` declared before they can send them.
|
||||
|
||||
`logging_callback` and `message_handler` are not in the table. They handle notifications, and notifications need no capability.
|
||||
|
||||
The server reads the declaration back with `ctx.session.check_client_capability(...)`. Add a tool that does:
|
||||
|
||||
```python title="server.py" hl_lines="23-31"
|
||||
--8<-- "docs_src/client_callbacks/tutorial003.py"
|
||||
```
|
||||
|
||||
Connect with only `elicitation_callback` and call it:
|
||||
|
||||
```python
|
||||
result.structured_content # {'result': ['elicitation']}
|
||||
```
|
||||
|
||||
Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pass none and you get `[]`.
|
||||
|
||||
!!! check
|
||||
Now do the wrong thing: connect **without** `elicitation_callback` and call `issue_card` anyway.
|
||||
|
||||
The server's `elicitation/create` request still reaches your client, and the SDK answers it for
|
||||
you, with an error, because you never said you could handle it. That error sinks the whole call.
|
||||
`call_tool` doesn't return an `is_error` result; it raises:
|
||||
|
||||
```text
|
||||
MCPError: Elicitation not supported
|
||||
```
|
||||
|
||||
That is a protocol error (`-32600`, *invalid request*), not a tool error: there is nothing for
|
||||
the model to read and retry. It's why `client_features` is worth having: a well-behaved server
|
||||
checks before it asks.
|
||||
|
||||
## The deprecated pair
|
||||
|
||||
`sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in.
|
||||
|
||||
Both work. Both follow the rule above. And both serve RPCs the **2026-07-28 spec removes**: a modern server doesn't call back into your client mid-request, it hands the request back to you as part of the tool result (**[Multi-round-trip requests](../handlers/multi-round-trip.md)**). The callbacks themselves are not dead. When an `InputRequiredResult` carries a `CreateMessageRequest` or a `ListRootsRequest`, `Client`'s auto-loop dispatches it to the same `sampling_callback` or `list_roots_callback` you registered here. The whole list is in **[Deprecated features](../deprecated.md)**.
|
||||
|
||||
You still need the callbacks to talk to servers that haven't moved. The signatures:
|
||||
|
||||
```python title="client.py"
|
||||
--8<-- "docs_src/client_callbacks/tutorial004.py"
|
||||
```
|
||||
|
||||
* A sampling callback receives the full `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) and returns a `CreateMessageResult`. *You* run the model, however you like; the SDK only carries the request.
|
||||
* A roots callback takes no params at all and returns a `ListRootsResult`.
|
||||
* Either one may return `ErrorData(...)` instead, to refuse.
|
||||
|
||||
Pass them to `Client(...)` exactly like `elicitation_callback`.
|
||||
|
||||
## The notification callbacks
|
||||
|
||||
Two more. Neither declares anything.
|
||||
|
||||
`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../handlers/logging.md)** has what to do instead), so this callback exists for the servers that still emit it.
|
||||
|
||||
`message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing.
|
||||
|
||||
## Recap
|
||||
|
||||
* A server can send requests to the client. You answer them with callbacks passed to `Client(...)`.
|
||||
* The elicitation callback is the current one: `async (context, params) -> ElicitResult`, one function for both form and URL mode.
|
||||
* **Registering a callback is declaring the capability.** Without it, the SDK refuses the server's request on your behalf and the whole call fails with `MCPError`.
|
||||
* A server finds out before asking with `ctx.session.check_client_capability(...)`.
|
||||
* `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead.
|
||||
* `logging_callback` and `message_handler` receive notifications. They declare nothing.
|
||||
|
||||
The first argument to `Client(...)` is a transport object. **[Client transports](transports.md)** covers every kind.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Identity assertion
|
||||
|
||||
An ordinary OAuth provider (**[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 page is both ends of that trade. The MCP server itself never changes: it is still the resource server from **[Authorization](../run/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](../run/authorization.md)**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In an ordinary OAuth flow, 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](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](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"
|
||||
```
|
||||
|
||||
Read it from the bottom.
|
||||
|
||||
* `main()` is the standard OAuth-client `main()` (**[OAuth clients](oauth-clients.md)**), unchanged 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.
|
||||
|
||||
### The assertion provider
|
||||
|
||||
`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](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,
|
||||
minutes-lived grant, and the authorization server on this page refuses to accept the same one
|
||||
twice. Do not cache it. The access token it buys you is the thing that gets reused.
|
||||
|
||||
### 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](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.
|
||||
|
||||
!!! warning
|
||||
The configured `issuer` is compared to the metadata document's `issuer` field by RFC 8414 §3.3
|
||||
simple string comparison: character for character, trailing slash included, no normalization.
|
||||
Do not guess it. Fetch `/.well-known/oauth-authorization-server` from your authorization server
|
||||
and copy the `issuer` value it returns. For the authorization server on this page that is
|
||||
`https://auth.example.com/`, with the slash, because its issuer was built from a pydantic URL
|
||||
object. A mismatch stops the flow at `OAuthFlowError: Authorization server metadata issuer
|
||||
mismatch` before a single credential or assertion is sent.
|
||||
|
||||
### A confidential client
|
||||
|
||||
`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.
|
||||
|
||||
### What the provider does for you
|
||||
|
||||
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](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](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](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:
|
||||
|
||||
```python title="auth_server.py" hl_lines="48-50 105-107"
|
||||
--8<-- "docs_src/identity_assertion/tutorial002.py"
|
||||
```
|
||||
|
||||
* `identity_assertion_enabled=True` gates everything. Off, which is the default, `/token` answers this grant with `unsupported_grant_type` even if you implemented the hook, and the metadata does not mention it. On, the metadata gains the `jwt-bearer` grant type and lists `urn:ietf:params:oauth:grant-profile:id-jag` in `authorization_grant_profiles_supported`, the field the extension uses to advertise support. (This SDK's client never reads it: it is provisioned for one issuer and simply asks.)
|
||||
* **`exchange_identity_assertion`** is the hook. Before it runs, the SDK has authenticated the client, refused public clients, and refused clients whose registration does not list the grant. You get an `IdentityAssertionParams` (the raw `assertion`, the requested `scopes` and `resource`) and return a plain `OAuthToken`.
|
||||
* Dynamic client registration refuses this grant unconditionally, so `get_client` here serves a hand-provisioned client. An ID-JAG client cannot register itself into existence.
|
||||
* Half the class is refusals. `OAuthAuthorizationServerProvider` is the *whole* authorization server, so it also asks for the authorization-code flow; a server that signs users in as well implements those for real, and this one has exactly one door.
|
||||
|
||||
!!! warning
|
||||
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](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.
|
||||
Track `jti` until the assertion's `exp` so it is accepted once. And take the granted scopes
|
||||
and, above all, the issued token's `resource` from the validated ID-JAG, never from the
|
||||
request: `params.resource` is whatever the client typed. The full processing rules are in the
|
||||
[Enterprise-Managed Authorization specification](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization).
|
||||
|
||||
Reject a bad assertion with `TokenError("invalid_grant", ...)`. The other error code in this flow is `invalid_target`: an ID-JAG that names a resource you do not serve is refused with it, which is what stops this server minting tokens for somebody else's. And the granted scopes come from the ID-JAG's `scope` claim (an assertion without one is refused too); yours might map the user's groups instead.
|
||||
|
||||
And notice what the returned `OAuthToken` does not carry: a refresh token. The IdP decides how long this user keeps access by deciding whether to issue the next ID-JAG. A refresh token minted here would quietly hand that decision back.
|
||||
|
||||
!!! info
|
||||
A server that still embeds its authorization server with `auth_server_provider=` reaches the same
|
||||
code through `AuthSettings(identity_assertion_enabled=True)`. **[Authorization](../run/authorization.md)** explains why new
|
||||
servers should not start there.
|
||||
|
||||
!!! check
|
||||
Wire the two files on this page together and the whole grant is one `POST /token`:
|
||||
|
||||
```text
|
||||
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
|
||||
assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ...
|
||||
client_id=finance-agent
|
||||
resource=http://localhost:8001/mcp
|
||||
scope=notes:read
|
||||
client_secret=finance-agent-secret
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
{"access_token": "mcp_...", "token_type": "Bearer", "expires_in": 300, "scope": "notes:read"}
|
||||
```
|
||||
|
||||
No `/authorize`, no `/register`, no protected-resource-metadata fetch. The only requests on the
|
||||
wire are the one that drew the `401`, the well-known fetch, this exchange, and then ordinary
|
||||
MCP traffic with the bearer attached. And the `sub` your validator read out of the ID-JAG is
|
||||
exactly what `get_access_token().subject` reports inside a tool.
|
||||
|
||||
### Try it
|
||||
|
||||
`examples/stories/identity_assertion/` in the SDK repository is this page running for real: the same `exchange_identity_assertion` validator, an MCP server gated on its tokens, a stand-in IdP, and the client, in one self-checking program. `uv run python -m stories.identity_assertion.client --http` runs the whole exchange and asserts that the user the IdP named is the user the tool sees.
|
||||
|
||||
## Recap
|
||||
|
||||
* [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](../run/authorization.md)**.
|
||||
@@ -0,0 +1,212 @@
|
||||
# The Client
|
||||
|
||||
A **`Client`** is how a Python program talks to an MCP server.
|
||||
|
||||
It is one object with one lifecycle: construct it, enter `async with`, call methods. Every protocol verb (list the tools, call one, read a resource, render a prompt) is an `async` method on it that returns a typed result.
|
||||
|
||||
## Your first client
|
||||
|
||||
```python title="client.py" hl_lines="14-18"
|
||||
--8<-- "docs_src/client/tutorial001.py"
|
||||
```
|
||||
|
||||
The server at the top is only there so you have something to connect to. The client is the five highlighted lines.
|
||||
|
||||
* `Client(mcp)` is given the **server object itself**. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example on this page, and every test you write, connects.
|
||||
* `async with` is the **lifecycle**. Entering it connects and negotiates; leaving it disconnects. There is no `connect()` / `close()` pair, and a `Client` cannot be reused after the block ends.
|
||||
* Inside the block the connection facts are already there as plain properties.
|
||||
|
||||
### What you can pass to `Client`
|
||||
|
||||
`Client` takes one positional argument and resolves the transport from its type:
|
||||
|
||||
* An `MCPServer` (or low-level `Server`) instance: connected **in-process**.
|
||||
* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path.
|
||||
* A **transport**: anything you can `async with ... as (read, write)`, such as `stdio_client(...)` wrapping a subprocess.
|
||||
|
||||
Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own page: **[Client transports](transports.md)**.
|
||||
|
||||
### What's on a connected client
|
||||
|
||||
Four read-only properties, populated the moment you enter the block:
|
||||
|
||||
* `client.server_info`: the server's identity. `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports.
|
||||
* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`.
|
||||
* `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`.
|
||||
* `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one.
|
||||
|
||||
You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **[Protocol versions](../protocol-versions.md)** has the whole story.
|
||||
|
||||
!!! tip
|
||||
`client.session` is the underlying `ClientSession`, the low-level escape hatch.
|
||||
You won't need it for anything on this page.
|
||||
|
||||
## Listing tools
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
--8<-- "docs_src/client/tutorial002.py"
|
||||
```
|
||||
|
||||
`list_tools()` returns a `ListToolsResult`; the tools are in `.tools`. Each one is the complete definition a host would hand to a model:
|
||||
|
||||
```python
|
||||
tool.name # 'search_books'
|
||||
tool.title # 'Search the catalog'
|
||||
tool.description # 'Search the catalog by title or author.'
|
||||
```
|
||||
|
||||
and `tool.input_schema` is the JSON Schema the server derived from the function's type hints:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"title": "Query", "type": "string"},
|
||||
"limit": {"default": 10, "title": "Limit", "type": "integer"}
|
||||
},
|
||||
"required": ["query"],
|
||||
"title": "search_booksArguments"
|
||||
}
|
||||
```
|
||||
|
||||
That schema is everything a UI needs to render an argument form, and everything a model needs to produce valid arguments.
|
||||
|
||||
!!! tip
|
||||
`title` is optional, so a UI showing tools to a human has to pick: the `title` if there is one,
|
||||
the `name` if not. `from mcp.shared.metadata_utils import get_display_name` does exactly that,
|
||||
for tools, resources, resource templates and prompts.
|
||||
|
||||
## Calling a tool
|
||||
|
||||
`call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`.
|
||||
|
||||
```python title="client.py" hl_lines="26-33"
|
||||
--8<-- "docs_src/client/tutorial003.py"
|
||||
```
|
||||
|
||||
The server's `lookup_book` returns a Pydantic `Book`. Here is what the client sees:
|
||||
|
||||
```python
|
||||
result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')]
|
||||
result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965}
|
||||
result.is_error # False
|
||||
```
|
||||
|
||||
One return value, three things to read. Each has a different consumer.
|
||||
|
||||
### `content`: what the model reads
|
||||
|
||||
`content` is a `list` of **content blocks**, and a content block is a union: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, or `EmbeddedResource`. A tool can return several, of different kinds.
|
||||
|
||||
That is why `main` narrows with `isinstance(block, TextContent)` before touching `block.text`. Notice there is no `.text` outside the `isinstance`: the type checker won't allow it, because `ImageContent` has `.data`, not `.text`. The union is honest about what a tool is allowed to send you; your code should be too.
|
||||
|
||||
### `structured_content`: what your application reads
|
||||
|
||||
`structured_content` is the tool's return value as JSON, matching the tool's declared `output_schema`. No string parsing, no guessing.
|
||||
|
||||
When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **[Structured Output](../servers/structured-output.md)** page.
|
||||
|
||||
### `is_error`: whether the tool failed
|
||||
|
||||
A tool that raises does **not** raise in your client. It comes back as an ordinary result with `is_error=True`.
|
||||
|
||||
!!! check
|
||||
Ask `lookup_book` for `"Solaris"` (a title that isn't in the catalog) and the function raises
|
||||
`ValueError`. The call still returns normally:
|
||||
|
||||
```python
|
||||
result.is_error # True
|
||||
result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
|
||||
result.structured_content # None
|
||||
```
|
||||
|
||||
The exception's message landed in `content`, where the **model** can read it and try again. That
|
||||
is deliberate: a tool error is part of the conversation, not a crash. Always look at `is_error`
|
||||
before you trust `structured_content`.
|
||||
|
||||
!!! warning
|
||||
`is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have
|
||||
(`call_tool("does_not_exist", {})`) and nothing raises. You get the same shape back,
|
||||
`is_error=True` with `Unknown tool: does_not_exist` in `content`. A `Client` method raises
|
||||
`MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and
|
||||
**[Handling errors](../servers/handling-errors.md)** covers when a server produces which.
|
||||
|
||||
## Resources
|
||||
|
||||
The resource verbs come in pairs: two ways to list, one way to read.
|
||||
|
||||
```python title="client.py" hl_lines="23-32"
|
||||
--8<-- "docs_src/client/tutorial004.py"
|
||||
```
|
||||
|
||||
* `list_resources()` returns the **concrete** resources, the ones with a fixed URI. Here: `['catalog://genres']`.
|
||||
* `list_resource_templates()` returns the **parameterised** ones. Here: `['catalog://genres/{genre}']`. They are two different lists because a template isn't readable until you fill it in.
|
||||
* `read_resource(uri)` takes a plain `str` URI and works on both: pass `"catalog://genres/poetry"` and the server matches it to the template.
|
||||
|
||||
`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`).
|
||||
|
||||
A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there - and consuming it with `client.listen(...)` is this section's **[Subscriptions](subscriptions.md)** page.
|
||||
|
||||
## Prompts
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
--8<-- "docs_src/client/tutorial005.py"
|
||||
```
|
||||
|
||||
`list_prompts()` tells you what the server offers and what each prompt needs:
|
||||
|
||||
```python
|
||||
prompt.name # 'recommend'
|
||||
prompt.title # 'Recommend a book'
|
||||
prompt.arguments # [PromptArgument(name='genre', required=True)]
|
||||
```
|
||||
|
||||
`get_prompt(name, arguments)` renders it. The arguments dict is `str -> str`: prompt arguments are always strings. The result is `messages`, a list of `PromptMessage`, each with a `role` and a `content` block:
|
||||
|
||||
```python
|
||||
message.role # 'user'
|
||||
message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')
|
||||
```
|
||||
|
||||
A host hands those messages straight to the model. That is the whole feature.
|
||||
|
||||
## Completions
|
||||
|
||||
A server with a completion handler can autocomplete prompt and resource-template arguments as the user types.
|
||||
|
||||
```python title="client.py" hl_lines="28-32"
|
||||
--8<-- "docs_src/client/tutorial006.py"
|
||||
```
|
||||
|
||||
* `ref` says *which* prompt or template you're filling in: a `PromptReference` or a `ResourceTemplateReference`.
|
||||
* `argument` is `{"name": ..., "value": ...}`: the argument and what the user has typed so far.
|
||||
|
||||
The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **[Completions](../servers/completions.md)** page.
|
||||
|
||||
## Pagination
|
||||
|
||||
Every `list_*` method takes a `cursor=` keyword and every result carries a `next_cursor`. When `next_cursor` is `None`, you have everything.
|
||||
|
||||
```python title="client.py" hl_lines="23-31"
|
||||
--8<-- "docs_src/client/tutorial007.py"
|
||||
```
|
||||
|
||||
This loop is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **[Pagination](../advanced/pagination.md)**.
|
||||
|
||||
## In tests
|
||||
|
||||
`Client(mcp)` with no process and no port is already a test harness for your server.
|
||||
|
||||
There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../get-started/testing.md)** is the page that explains it and builds the whole pattern around it.
|
||||
|
||||
## Recap
|
||||
|
||||
* `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport.
|
||||
* `async with` is the whole lifecycle. Inside it, `server_info`, `server_capabilities`, `protocol_version` and `instructions` are already populated.
|
||||
* `list_tools()` gives you each tool's `name`, `title`, `description` and `input_schema`.
|
||||
* `call_tool()` returns `content` for the model, `structured_content` for your code, and `is_error`. A raising tool is a result, not an exception.
|
||||
* `content` is a union of block types; narrow with `isinstance` before reading.
|
||||
* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, and `complete` round out the verbs.
|
||||
* Every `list_*` takes `cursor=`; loop until `next_cursor` is `None`.
|
||||
|
||||
The things a server can ask the *client* for, and how you answer them, are **[Client callbacks](callbacks.md)**.
|
||||
@@ -0,0 +1,147 @@
|
||||
# OAuth clients
|
||||
|
||||
Some MCP servers are protected. Send them a request without a token and they answer `401 Unauthorized`.
|
||||
|
||||
**`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 page is the client side. Making your own server demand a token is **[Authorization](../run/authorization.md)**.
|
||||
|
||||
## The provider
|
||||
|
||||
```python title="client.py" hl_lines="44-54"
|
||||
--8<-- "docs_src/oauth_clients/tutorial001.py"
|
||||
```
|
||||
|
||||
You give it four things:
|
||||
|
||||
* `server_url`: the MCP endpoint you are connecting to. The provider discovers everything else from it.
|
||||
* `client_metadata`: what you would type into an authorization server's "register an application" form.
|
||||
* `storage`: where tokens live between runs.
|
||||
* `redirect_handler` and `callback_handler`: the two moments a human is involved.
|
||||
|
||||
Nothing else in the file mentions OAuth. `main()` never sees a token.
|
||||
|
||||
### Client metadata
|
||||
|
||||
`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.
|
||||
|
||||
!!! check
|
||||
Because it is a Pydantic model, it validates **before a single byte goes over the network**.
|
||||
Leave out `redirect_uris` and construction fails on the spot with a `ValidationError` that
|
||||
names the field:
|
||||
|
||||
```text
|
||||
redirect_uris
|
||||
Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict]
|
||||
```
|
||||
|
||||
No browser opened, no half-finished registration left behind on the authorization server.
|
||||
|
||||
### Token storage
|
||||
|
||||
**`TokenStorage`** is a `Protocol` with four async methods. You don't inherit from anything; write the methods and any class is a token store:
|
||||
|
||||
* `get_tokens` / `set_tokens` hold the `OAuthToken`: access token, refresh token, expiry, scope.
|
||||
* `get_client_info` / `set_client_info` hold the `OAuthClientInformationFull` the authorization server issued when the provider registered you, including your `client_id`.
|
||||
|
||||
The in-memory version above works. It also forgets everything when the process exits, so the next run does the whole dance again. Persist it to a file or your platform's keyring and the next run is silent.
|
||||
|
||||
!!! tip
|
||||
Store `client_info`, not only the tokens. The provider registers dynamically the first time it
|
||||
finds no stored `client_info`. Throw it away and you mint a fresh registration on every run.
|
||||
|
||||
### The two handlers
|
||||
|
||||
The authorization code flow needs a human exactly once: someone has to sign in and click "allow".
|
||||
|
||||
* **`redirect_handler`** is awaited with the fully-built authorization URL. The `client_id`, the `redirect_uri`, the `state` and the PKCE challenge are already in it. Your only job is to get a browser there. A desktop app calls `webbrowser.open`; this file prints it.
|
||||
* **`callback_handler`** is awaited next. It waits until the user lands back on your `redirect_uri` and returns that redirect's query parameters as an `AuthorizationCodeResult`.
|
||||
|
||||
A real client runs a small local HTTP server on the redirect URI instead of calling `input()`. The shape is identical: get redirected, hand back `code`, `state`, and `iss`.
|
||||
|
||||
!!! warning
|
||||
Pass `state` and `iss` through exactly as they arrived. The provider compares `state` to the one
|
||||
it generated and `iss` to the issuer it discovered, and refuses a mismatch. They are the CSRF
|
||||
and server-mix-up defences.
|
||||
|
||||
### Into the `Client`
|
||||
|
||||
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](transports.md)**.
|
||||
|
||||
## What the provider does for you
|
||||
|
||||
The first time `Client` sends a request, the server answers `401`. The provider takes over:
|
||||
|
||||
1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata.
|
||||
2. **Registration.** Nothing in storage? It registers you dynamically with your `OAuthClientMetadata` and stores the result.
|
||||
3. **Authorization.** It generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code.
|
||||
4. **Exchange.** It trades the code for an `OAuthToken`, stores it, and replays your original request with `Authorization: Bearer ...`.
|
||||
|
||||
After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again.
|
||||
|
||||
You wrote none of it. Three keyword arguments remain (`timeout`, `client_metadata_url` and `validate_resource_url`), and this file needs none of them. `client_metadata_url` is the one worth knowing about; it gets its own section below.
|
||||
|
||||
### Try it
|
||||
|
||||
Most examples in these docs you can check with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server.
|
||||
|
||||
The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this page's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by.
|
||||
|
||||
## Client ID Metadata Documents
|
||||
|
||||
The 2026-07-28 revision of the spec deprecates dynamic client registration in favor of **Client ID Metadata Documents** (CIMD). Instead of POSTing a fresh registration to every authorization server it meets, your client publishes one JSON document about itself at a stable HTTPS URL, and that URL *is* its `client_id`. The authorization server fetches the document; the provider never touches it.
|
||||
|
||||
The SDK already speaks it: pass the URL as `client_metadata_url=` when you construct the provider. When the authorization server's metadata advertises `client_id_metadata_document_supported: true`, the provider skips the `/register` request entirely: the URL goes into the flow as the `client_id`, and there is no `client_secret`. When the server doesn't advertise it (most don't yet), or you never pass a URL, the provider falls back to dynamic registration **silently**, and everything above works exactly as described. Stored `client_info` still wins over both.
|
||||
|
||||
The URL must be HTTPS with a non-root path; anything else is a `ValueError` at construction, before any network happens. The shipped `examples/clients/simple-auth-client/` takes it as the `MCP_CLIENT_METADATA_URL` environment variable.
|
||||
|
||||
## Machine to machine
|
||||
|
||||
A nightly job, a CI step, another service. There is no browser and nobody to click "allow". That is the **client credentials** grant: you already hold a `client_id` and a `client_secret`, and the token endpoint is the whole flow.
|
||||
|
||||
`ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the human:
|
||||
|
||||
```python title="client.py" hl_lines="4 27-33"
|
||||
--8<-- "docs_src/oauth_clients/tutorial002.py"
|
||||
```
|
||||
|
||||
What changed:
|
||||
|
||||
* No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely.
|
||||
* `scopes` is a space-separated string, the OAuth wire format.
|
||||
* Everything downstream is identical: the same `TokenStorage`, the same `httpx.AsyncClient(auth=...)`, the same `streamable_http_client`.
|
||||
|
||||
By default the secret travels as HTTP Basic auth on the token request (`client_secret_basic`). Pass `token_endpoint_auth_method="client_secret_post"` to put it in the form body instead. Some authorization servers only accept one of the two.
|
||||
|
||||
!!! tip
|
||||
Read `client_secret` from the environment or a secret manager, never from source control.
|
||||
|
||||
!!! info
|
||||
One more provider lives in `mcp.client.auth.extensions.client_credentials`:
|
||||
**`PrivateKeyJWTOAuthProvider`**, for clients that authenticate with a JWT instead of a
|
||||
shared secret (`private_key_jwt`, the key-pair and workload-identity flavour). It follows
|
||||
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 page, **[Identity assertion](identity-assertion.md)**.
|
||||
|
||||
## When it fails
|
||||
|
||||
When the OAuth flow goes wrong, the provider raises an `OAuthFlowError` from `mcp.client.auth`. It has two subclasses. `OAuthRegistrationError` means the authorization server refused to register you. `OAuthTokenError` means the token endpoint said no. One `except OAuthFlowError:` covers discovery, registration, authorization, and exchange.
|
||||
|
||||
Not everything is a flow error. The network can still fail; those are ordinary `httpx` exceptions and pass through untouched.
|
||||
|
||||
## Recap
|
||||
|
||||
* `OAuthClientProvider` is an `httpx.Auth`. Put it on an `httpx.AsyncClient`, pass that to `streamable_http_client(url, http_client=...)`, and `Client` never knows OAuth happened.
|
||||
* You supply four things: the server URL, an `OAuthClientMetadata`, a `TokenStorage`, and the redirect/callback handler pair.
|
||||
* `TokenStorage` is a `Protocol`: four async methods, no base class. Persist `client_info` as well as the tokens.
|
||||
* Discovery, registration (dynamic, or via a **Client ID Metadata Document**), PKCE, the `state` and `iss` checks, and token refresh are the provider's job, not yours.
|
||||
* `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](../run/authorization.md)**.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Session groups
|
||||
|
||||
A `Client` connects to one server. Real applications often want several (a search server, a database server, an internal API) and end up juggling a connection and a tool list for each.
|
||||
|
||||
**`ClientSessionGroup`** is one object that holds many connections and merges everything they expose into a single view.
|
||||
|
||||
## Two servers
|
||||
|
||||
Start with two ordinary servers. They have nothing to do with each other, so both naturally called their tool `search`:
|
||||
|
||||
```python title="library_server.py" hl_lines="7"
|
||||
--8<-- "docs_src/session_groups/tutorial001.py"
|
||||
```
|
||||
|
||||
```python title="web_server.py" hl_lines="7"
|
||||
--8<-- "docs_src/session_groups/tutorial002.py"
|
||||
```
|
||||
|
||||
## One group
|
||||
|
||||
Create a `ClientSessionGroup` and call **`connect_to_server`** once per server:
|
||||
|
||||
```python title="client.py" hl_lines="10-12"
|
||||
--8<-- "docs_src/session_groups/tutorial003.py"
|
||||
```
|
||||
|
||||
* `connect_to_server` takes transport parameters, not a server object: `StdioServerParameters` (from `mcp`) to launch a subprocess, or `StreamableHttpParameters` / `SseServerParameters` (from `mcp.client.session_group`) for a server already listening on a URL.
|
||||
* `group.tools` is a `dict[str, Tool]` of every connected server's tools. `group.resources` and `group.prompts` are the same shape.
|
||||
* `group.call_tool(name, arguments)` looks the name up, finds the session that owns it, and forwards the call. You never say which server.
|
||||
|
||||
!!! check
|
||||
Put `client.py` next to the two servers and run it. The second `connect_to_server` refuses:
|
||||
|
||||
```text
|
||||
mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.
|
||||
```
|
||||
|
||||
That is an `MCPError`, raised before anything from the second server is registered. A name must
|
||||
be unique across the **whole** group, and two servers you don't control will collide eventually.
|
||||
|
||||
## `component_name_hook`
|
||||
|
||||
You fix this at the group, not at the servers. Pass a function of `(name, server_info)` and the group runs it on every name it registers:
|
||||
|
||||
```python title="client.py" hl_lines="8-9 16"
|
||||
--8<-- "docs_src/session_groups/tutorial004.py"
|
||||
```
|
||||
|
||||
Run it again. `print(sorted(group.tools))` now shows both:
|
||||
|
||||
```text
|
||||
['Library.search', 'Web.search']
|
||||
```
|
||||
|
||||
* The **key** is yours. `by_server` built it from `server_info.name`, the name each `MCPServer(...)` was constructed with.
|
||||
* The `Tool` inside is untouched: `group.tools["Web.search"].name` is still `"search"`, and that is the name `call_tool` puts on the wire. The prefix never leaves your process.
|
||||
* It is not only tools. The library's `hours` resource is registered as `Library.hours`.
|
||||
|
||||
!!! tip
|
||||
The hook runs on **every** name from **every** server, not only on conflicts: there is no
|
||||
prefix-on-collision mode. Pick one scheme and let it apply everywhere.
|
||||
|
||||
## Adding and removing servers
|
||||
|
||||
`connect_to_server` returns the `ClientSession` it opened. Keep it if you ever want that server gone: `await group.disconnect_from_server(session)` removes its tools, resources, and prompts from the group.
|
||||
|
||||
If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open.
|
||||
|
||||
## 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](../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
|
||||
|
||||
* `ClientSessionGroup` holds many server connections and merges their tools, resources, and prompts into one `dict` each.
|
||||
* `connect_to_server(params)` per server. It takes transport parameters, never the server object or URL a `Client` takes.
|
||||
* `group.call_tool(name, arguments)` routes to the owning server for you.
|
||||
* Names must be unique across the whole group; two servers with a `search` tool cannot coexist on their own.
|
||||
* `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](../protocol-versions.md)**.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Subscriptions
|
||||
|
||||
A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes. A client hears about it through `client.listen(...)`: one `subscriptions/listen` request whose response *is* the stream. It stays open and carries the change notifications the client asked for.
|
||||
|
||||
This page is the client end: opening the stream, watching it beside your main flow, and handling its endings. Publishing changes, filtering, and serving the method are the server's side of the story, told in **[Subscriptions](../handlers/subscriptions.md)** under *Inside your handler*. The examples here talk to the sprint-board server built there.
|
||||
|
||||
## Watching the stream
|
||||
|
||||
A subscription is one context manager. Entering it sends the request, with your keyword arguments as the subscription filter, and waits for the server's acknowledgment, so the stream is live by the time the block starts.
|
||||
|
||||
```python title="client.py" hl_lines="16 19 29"
|
||||
--8<-- "docs_src/subscriptions/tutorial003.py"
|
||||
```
|
||||
|
||||
Iteration yields four typed events: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, and `ResourceUpdated(uri=...)`.
|
||||
|
||||
An event says *what* changed, never *how*. That is why `follow_board` calls `read_resource` and `list_tools`: the event is a cue to refetch. Read `event.uri` rather than assuming which resource moved: a filter can name several URIs, and a server may report a change on a sub-resource of one of them.
|
||||
|
||||
Duplicate events waiting to be consumed collapse into one, and refetching still gets you the current state. Only identical events collapse: two `ResourceUpdated` for different URIs are two events.
|
||||
|
||||
Two more properties of the handle:
|
||||
|
||||
* `sub.honored` is the filter the server acknowledged: a `SubscriptionFilter` with the fields you passed, read as attributes (`sub.honored.prompts_list_changed`). `MCPServer` honors every kind you ask for, so it echoes your request back. A server that narrows the filter (see the [filter warning](../handlers/subscriptions.md#only-what-was-asked-for) on the server page) acknowledges less, and an honored kind may still never fire.
|
||||
* `sub.subscription_id` is the listen request's id, the one stamped on every frame of this stream. Several subscriptions can be open at once, each demultiplexed by its own id.
|
||||
|
||||
## Watching without blocking
|
||||
|
||||
`follow_board` runs until the server closes the stream, which may be never, so on its own it owns your program. Real clients want the watcher *beside* the main flow: an agent calls tools while a watcher keeps a cache or a UI current.
|
||||
|
||||
Open the subscription first, then start the watcher and get on with your work.
|
||||
|
||||
=== "asyncio"
|
||||
|
||||
```python title="app.py" hl_lines="18 20"
|
||||
--8<-- "docs_src/subscriptions/tutorial004_asyncio.py"
|
||||
```
|
||||
|
||||
=== "trio"
|
||||
|
||||
```python title="app.py" hl_lines="18 21"
|
||||
--8<-- "docs_src/subscriptions/tutorial004_trio.py"
|
||||
```
|
||||
|
||||
=== "anyio"
|
||||
|
||||
```python title="app.py" hl_lines="18 21"
|
||||
--8<-- "docs_src/subscriptions/tutorial004_anyio.py"
|
||||
```
|
||||
|
||||
!!! note
|
||||
`app.py` imports `BOARD` and `read_board` from the first example, which this repo stores as
|
||||
`tutorial003.py`. If you save the rendered files side by side as `client.py` and `app.py`,
|
||||
write `from client import BOARD, read_board` instead. The `watch.py` example further down
|
||||
imports `read_board` the same way.
|
||||
|
||||
The order is the point. Nothing is replayed, so an event published before your stream existed is missed. Entering `client.listen(...)` waits for the acknowledgment, so every change from that moment on reaches your watcher, and the snapshot you take inside the block cannot miss one.
|
||||
|
||||
Requests run freely beside an open stream, from the watcher task or any other, on the same client. Because *duplicate* unconsumed events coalesce, a busy main flow may produce one refetch rather than three. Events that differ do not coalesce: a filter naming many URIs queues one pending event per URI.
|
||||
|
||||
To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP, by closing that request's stream. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown.
|
||||
|
||||
## Streams end
|
||||
|
||||
A stream ends in one of two ways, both ordinary control flow. A graceful server close ends the `async for`; an abrupt drop raises `SubscriptionLost`.
|
||||
|
||||
The difference is diagnostic, not a difference in what to do next: the stream is gone, nothing was replayed, and a watcher that still cares re-listens and refetches.
|
||||
|
||||
```python title="watch.py" hl_lines="16 20"
|
||||
--8<-- "docs_src/subscriptions/tutorial005.py"
|
||||
```
|
||||
|
||||
Servers close streams gracefully for their own reasons, including shedding a subscriber whose backlog grew too large, so a clean end is not a signal to stop watching. Back off before re-listening.
|
||||
|
||||
`SubscriptionLost` has one local cause too. The client holds at most 1024 unconsumed events, and a consumer that falls that far behind loses the subscription rather than grow without bound. Keep the body of the `async for` short and do slow work elsewhere.
|
||||
|
||||
`keep_following` catches only `SubscriptionLost`. Entering `listen()` can also raise `MCPError` (the connection failed, or the server does not serve the method), `TimeoutError` (no acknowledgment arrived), and `ListenNotSupportedError` (a pre-2026 connection). Decide which of those your watcher should retry: the last never heals.
|
||||
|
||||
## Recap
|
||||
|
||||
* Enter `async with client.listen(...)`; entering waits for the acknowledgment, so nothing published after it is missed.
|
||||
* Iterate with `async for event in sub`. Events are cues to refetch, never payloads.
|
||||
* Open the subscription, then run the watcher as a task, and tool calls keep flowing beside it.
|
||||
* A clean end stops the loop; a drop raises `SubscriptionLost`. Either way: re-listen, refetch, back off first.
|
||||
* Leaving the block is the unsubscribe.
|
||||
|
||||
Publishing these events, narrowing the filter, and scaling past one process are the server's story: **[Subscriptions](../handlers/subscriptions.md)**. These same events also keep a client-side cache honest, and **[Caching](caching.md)** is the next page.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Client transports
|
||||
|
||||
Every `Client` talks to its server over a **transport**: the thing that actually carries the messages.
|
||||
|
||||
You never configure one separately. `Client` takes a single positional argument and works the transport out from its type.
|
||||
|
||||
The *server* side of each (what `mcp.run()` does and what you deploy) is **[Running your server](../run/index.md)**.
|
||||
|
||||
## In memory
|
||||
|
||||
Pass the server object itself:
|
||||
|
||||
```python title="client.py" hl_lines="14"
|
||||
--8<-- "docs_src/client_transports/tutorial001.py"
|
||||
```
|
||||
|
||||
No subprocess, no port, no bytes on a wire. The client and the server are two objects in the same process, and the call still goes through the real protocol layer: `search_books` is listed, validated and invoked exactly as it would be over HTTP.
|
||||
|
||||
That makes it two things at once:
|
||||
|
||||
* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../get-started/testing.md)** page builds the whole pattern around it.
|
||||
* **An embedding API.** An application that constructs the server doesn't need a network hop to call its tools.
|
||||
|
||||
## Streamable HTTP
|
||||
|
||||
Pass a URL string and you get **Streamable HTTP**, the transport you deploy behind:
|
||||
|
||||
```python title="client.py" hl_lines="5"
|
||||
--8<-- "docs_src/client_transports/tutorial002.py"
|
||||
```
|
||||
|
||||
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
|
||||
|
||||
!!! check
|
||||
A `Client` you have constructed is **not** connected. Construction only picks the transport;
|
||||
`async with` is what opens it. Reach for the connection before entering and the SDK tells you so:
|
||||
|
||||
```text
|
||||
RuntimeError: Client must be used within an async context manager
|
||||
```
|
||||
|
||||
Nothing was resolved, fetched or spawned when you wrote `Client("http://...")`. That line is free.
|
||||
|
||||
### Bring your own `httpx.AsyncClient`
|
||||
|
||||
The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx.AsyncClient` yourself and hand it to `streamable_http_client`:
|
||||
|
||||
```python title="client.py" hl_lines="8-14"
|
||||
--8<-- "docs_src/client_transports/tutorial003.py"
|
||||
```
|
||||
|
||||
Two things to notice:
|
||||
|
||||
* You own the `httpx.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create.
|
||||
* `streamable_http_client(url, http_client=...)` returns a transport, and `Client(transport)` accepts it like anything else.
|
||||
|
||||
!!! warning
|
||||
`streamable_http_client` used to take `headers=` and `timeout=` directly. It does not any more:
|
||||
its only parameters are `url`, `http_client` and `terminate_on_close`. Reach for `headers=` out
|
||||
of habit and you get:
|
||||
|
||||
```text
|
||||
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
|
||||
```
|
||||
|
||||
Everything HTTP-shaped now lives on the one `httpx.AsyncClient` you pass in.
|
||||
|
||||
!!! info
|
||||
If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection
|
||||
limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in:
|
||||
`httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
|
||||
|
||||
## stdio
|
||||
|
||||
A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine: a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file.
|
||||
|
||||
Describe the process with `StdioServerParameters`, turn it into a transport with `stdio_client`, and hand *that* to `Client`:
|
||||
|
||||
```python title="client.py" hl_lines="4-8 12"
|
||||
--8<-- "docs_src/client_transports/tutorial004.py"
|
||||
```
|
||||
|
||||
`Client` does not accept the parameters object on its own. `StdioServerParameters` is configuration; `stdio_client(server)` is the transport that knows how to spawn a process from it. Always wrap.
|
||||
|
||||
Leaving the `async with` block also shuts the subprocess down: close stdin, wait, kill if it lingers. You never clean it up yourself.
|
||||
|
||||
!!! warning
|
||||
The child does **not** inherit your environment. It gets a minimal allow-list (`HOME`, `LOGNAME`,
|
||||
`PATH`, `SHELL`, `TERM` and `USER` on POSIX) so nothing sensitive leaks into a process you may
|
||||
not have written.
|
||||
|
||||
A server that needs an API key won't find it there. Pass it explicitly with `env=`; those
|
||||
variables are merged on top of the allow-list. That is what `BOOKSHOP_API_KEY` is doing above.
|
||||
|
||||
## SSE
|
||||
|
||||
`sse_client(url)`, from `mcp.client.sse`, is the HTTP transport that Streamable HTTP superseded. Wrap it the same way, `Client(sse_client("http://localhost:8000/sse"))`, to talk to a server that still speaks it, and don't build anything new on it.
|
||||
|
||||
## The `Transport` protocol
|
||||
|
||||
To `Client`, all of the above are the same thing.
|
||||
|
||||
A **transport** is any async context manager that yields a `(read, write)` pair of message streams: formally, the `Transport` protocol in `mcp.client`. `Client` resolves its argument by type: a server object connects in-process, a `str` becomes `streamable_http_client(url)`, and anything else is entered as a transport directly. That last rule is why `stdio_client(...)`, `streamable_http_client(...)` and `sse_client(...)` all drop into the same slot, and why you can write your own.
|
||||
|
||||
## Recap
|
||||
|
||||
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
|
||||
* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
|
||||
* Headers, auth, proxies and timeouts belong on an `httpx.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
|
||||
* stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone.
|
||||
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
|
||||
* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol.
|
||||
* Constructing a `Client` picks the transport. `async with` opens it.
|
||||
|
||||
Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **[Protocol versions](../protocol-versions.md)** is the page.
|
||||
Reference in New Issue
Block a user