docs: restructure into topical sections and add the four most-asked-for pages (#3044)
This commit is contained in:
parent
080f2a869d
commit
220d362112
108 changed files with 2403 additions and 343 deletions
|
|
@ -138,9 +138,10 @@ rather than adding new standalone sections.
|
|||
## Documentation
|
||||
|
||||
When a change affects public API or user-visible behaviour, update the relevant
|
||||
page(s) under `docs/` in the same PR. Docs are organised by topic
|
||||
(`tutorial/`, `client/`, `run/`, `advanced/`) — find the page covering the
|
||||
feature you touched rather than adding a new one.
|
||||
page(s) under `docs/` in the same PR. Docs are organised by the `nav:` sections
|
||||
in `mkdocs.yml` (Get started, Servers, Inside your handler, Running your server,
|
||||
Clients, Advanced), not by the on-disk directory names. Find the page covering
|
||||
the feature you touched in `mkdocs.yml` rather than adding a new one.
|
||||
|
||||
## Formatting & Type Checking
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
**The documentation lives at <https://py.sdk.modelcontextprotocol.io/v2/>.**
|
||||
|
||||
It has the full [tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/).
|
||||
It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/v2/get-started/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/).
|
||||
|
||||
## What is MCP?
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ Call `add` with `a=1`, `b=2` and you get `3` back.
|
|||
|
||||
Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring.
|
||||
|
||||
[The tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/) takes it from here.
|
||||
[Get started](https://py.sdk.modelcontextprotocol.io/v2/get-started/) takes it from here.
|
||||
|
||||
## A client in 10 lines
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ the publish job — `skip-existing` makes it skip whatever already landed. The
|
|||
|
||||
1. Update the pre-release version examples in `README.md` and the docs
|
||||
(grep the outgoing version — the pins live in the README Installation
|
||||
section, `docs/index.md`, and `docs/installation.md`) so the tagged
|
||||
section, `docs/index.md`, `docs/get-started/installation.md`, and `docs/get-started/real-host.md`) so the tagged
|
||||
commit — and therefore the README PyPI publishes — names the version
|
||||
being released. When entering a new phase (alpha → beta → rc), update
|
||||
the banner wording too.
|
||||
|
|
|
|||
29
docs/advanced/index.md
Normal file
29
docs/advanced/index.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Advanced
|
||||
|
||||
Everything an ordinary server or client needs has a topical home in the sections above.
|
||||
This section is the escape hatches you reach for when `MCPServer`'s convenience
|
||||
layer is in the way:
|
||||
|
||||
* **[The low-level Server](low-level-server.md)**: the class `MCPServer` is built on.
|
||||
Hand-written schemas, `on_*` handlers, nothing checked for you, and custom JSON-RPC
|
||||
methods of your own.
|
||||
* **[Pagination](pagination.md)** and **[Middleware](middleware.md)**: two things you
|
||||
can *only* do on the low-level `Server`.
|
||||
* **[Extensions](extensions.md)** and **[MCP Apps](apps.md)**: the protocol's
|
||||
extension surface. Compose extension packages into a server, or write your own.
|
||||
|
||||
A few things you might reasonably look for here live where you'd actually use them
|
||||
instead:
|
||||
|
||||
* **Authorization** is under **[Running your server](../run/index.md)** because you
|
||||
protect a server where you deploy it.
|
||||
* **OAuth**, **identity assertion**, connecting to **multiple servers**, and the
|
||||
response **cache** are all under **[Clients](../client/index.md)**.
|
||||
* **Multi-round-trip requests** and **Subscriptions** are under
|
||||
**[Inside your handler](../handlers/index.md)** because both are things a
|
||||
handler *does*.
|
||||
* **URI templates** is under **[Servers](../servers/index.md)**, next to Resources.
|
||||
* **[Protocol versions](../protocol-versions.md)** and
|
||||
**[Deprecated features](../deprecated.md)** each have their own top-level page.
|
||||
|
||||
If you're not sure whether you need this section, you don't.
|
||||
|
|
@ -12,7 +12,7 @@ For everything else, stay on `MCPServer`.
|
|||
|
||||
## The same tool, by hand
|
||||
|
||||
This is `search_books` from **[Tools](../tutorial/tools.md)** (the nine-line `@mcp.tool()` file) with the sugar removed:
|
||||
This is the `search_books` tool that **[Tools](../servers/tools.md)** writes in nine lines of `@mcp.tool()`, with the sugar removed:
|
||||
|
||||
```python title="server.py" hl_lines="23 27 33"
|
||||
--8<-- "docs_src/lowlevel/tutorial001.py"
|
||||
|
|
@ -56,12 +56,12 @@ asyncio.run(main())
|
|||
|
||||
The same text the `@mcp.tool()` version produced. Two honest differences:
|
||||
|
||||
* `result.structured_content` is `None`. The high-level server wrapped your `-> str` into `{"result": ...}`; here nobody builds what you didn't build.
|
||||
* `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build.
|
||||
* `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there.
|
||||
|
||||
## Nothing is checked for you
|
||||
|
||||
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.
|
||||
`MCPServer` rejects a bad argument before your function ever runs, validating the call against the schema it generated (**[Tools](../servers/tools.md)**).
|
||||
|
||||
`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](../tutorial/tools.md)** you saw a bad argument get rejected before
|
|||
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](../tutorial/testing.md)**.)
|
||||
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](../get-started/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](../tutorial/handling-errors.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](../servers/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](../tutorial/structured-output.md)**.
|
||||
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](../servers/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](../tutorial/lifespan.md)**.
|
||||
* 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](../handlers/lifespan.md)**.
|
||||
|
||||
Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`.
|
||||
|
||||
|
|
@ -179,11 +179,11 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other
|
|||
|
||||
## The other handlers
|
||||
|
||||
Each of these is one idea you now have the vocabulary for; each has its own chapter.
|
||||
Each of these is one idea you now have the vocabulary for; each has its own page.
|
||||
|
||||
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**).
|
||||
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**).
|
||||
* `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.
|
||||
* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](subscriptions.md)** for the full composition.
|
||||
* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition.
|
||||
* `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
|
||||
|
|
|
|||
|
|
@ -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.md)**.
|
||||
**[OpenTelemetry](../run/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.md)**.
|
||||
**[OpenTelemetry](../run/opentelemetry.md)**.
|
||||
* The whole surface is provisional. Observe with it; don't build on it.
|
||||
|
||||
That is everything that wraps a request. **[Authorization](authorization.md)** is what decides whether the request
|
||||
That is everything that wraps a request. **[Authorization](../run/authorization.md)** is what decides whether the request
|
||||
gets to run at all.
|
||||
|
|
|
|||
|
|
@ -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](../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.
|
||||
This is the same loop **[The Client](../client/index.md)** shows for every `list_*` verb, 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
|
||||
|
||||
|
|
|
|||
117
docs/client/caching.md
Normal file
117
docs/client/caching.md
Normal file
|
|
@ -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.
|
||||
|
||||
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.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Client callbacks
|
||||
|
||||
So far every request has gone one way: client to server.
|
||||
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(...)`.
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ Here is a server whose tool can't finish on its own:
|
|||
* `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](../tutorial/elicitation.md)** chapter owns it. This chapter is the other end of the wire.
|
||||
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
|
||||
|
||||
|
|
@ -31,7 +31,7 @@ That is the server half, and the **[Elicitation](../tutorial/elicitation.md)** c
|
|||
!!! 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](../tutorial/elicitation.md)** shows the full pattern.
|
||||
**[Elicitation](../handlers/elicitation.md)** shows the full pattern.
|
||||
|
||||
### Try it
|
||||
|
||||
|
|
@ -59,11 +59,11 @@ One `tools/call` from you, one `elicitation/create` back from the server, answer
|
|||
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.
|
||||
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](../advanced/multi-round-trip.md)**.
|
||||
`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
|
||||
|
||||
|
|
@ -113,7 +113,7 @@ Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pas
|
|||
|
||||
`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](../advanced/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](../advanced/deprecated.md)**.
|
||||
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:
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ Pass them to `Client(...)` exactly like `elicitation_callback`.
|
|||
|
||||
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](../tutorial/logging.md)** has what to do instead), so this callback exists for the servers that still emit it.
|
||||
`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.
|
||||
|
||||
|
|
@ -144,4 +144,4 @@ Two more. Neither declares anything.
|
|||
* `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.
|
||||
|
||||
Next: the first argument you've been passing to `Client(...)` all along, **[Client transports](transports.md)**.
|
||||
The first argument to `Client(...)` is a transport object. **[Client transports](transports.md)** covers every kind.
|
||||
|
|
|
|||
146
docs/client/identity-assertion.md
Normal file
146
docs/client/identity-assertion.md
Normal file
|
|
@ -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)**.
|
||||
|
|
@ -12,7 +12,7 @@ It is one object with one lifecycle: construct it, enter `async with`, call meth
|
|||
|
||||
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 in this chapter, and every test you write, connects.
|
||||
* `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.
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ The server at the top is only there so you have something to connect to. The cli
|
|||
* 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 chapter: **[Client transports](transports.md)**.
|
||||
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
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ Four read-only properties, populated the moment you enter the block:
|
|||
* `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.
|
||||
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.
|
||||
|
|
@ -104,7 +104,7 @@ That is why `main` narrows with `isinstance(block, TextContent)` before touching
|
|||
|
||||
`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](../tutorial/structured-output.md)** chapter.
|
||||
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
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ A tool that raises does **not** raise in your client. It comes back as an ordina
|
|||
(`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](../tutorial/handling-errors.md)** covers when a server produces which.
|
||||
**[Handling errors](../servers/handling-errors.md)** covers when a server produces which.
|
||||
|
||||
## Resources
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read.
|
|||
|
||||
`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 the server side of the story is **[Subscriptions](../advanced/subscriptions.md)**.
|
||||
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 the server side of the story is **[Subscriptions](../handlers/subscriptions.md)**.
|
||||
|
||||
## Prompts
|
||||
|
||||
|
|
@ -181,7 +181,7 @@ A server with a completion handler can autocomplete prompt and resource-template
|
|||
* `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](../tutorial/completions.md)** chapter.
|
||||
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
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ This loop is correct against every server. `MCPServer` returns everything in one
|
|||
|
||||
`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](../tutorial/testing.md)** is the chapter that explains it and builds the whole pattern around it.
|
||||
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
|
||||
|
||||
|
|
@ -209,4 +209,4 @@ There is one constructor flag built for that: `Client(mcp, raise_exceptions=True
|
|||
* `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`.
|
||||
|
||||
Next: the things a server can ask the *client* for, and how you answer, in **[Client callbacks](callbacks.md)**.
|
||||
The things a server can ask the *client* for, and how you answer them, are **[Client callbacks](callbacks.md)**.
|
||||
|
|
|
|||
147
docs/client/oauth-clients.md
Normal file
147
docs/client/oauth-clients.md
Normal file
|
|
@ -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)**.
|
||||
82
docs/client/session-groups.md
Normal file
82
docs/client/session-groups.md
Normal file
|
|
@ -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)**.
|
||||
|
|
@ -18,7 +18,7 @@ No subprocess, no port, no bytes on a wire. The client and the server are two ob
|
|||
|
||||
That makes it two things at once:
|
||||
|
||||
* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../tutorial/testing.md)** chapter builds the whole pattern around it.
|
||||
* **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
|
||||
|
|
@ -68,11 +68,11 @@ Two things to notice:
|
|||
!!! 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](../advanced/oauth-clients.md)**.
|
||||
`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 **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`:
|
||||
|
||||
|
|
@ -112,4 +112,4 @@ A **transport** is any async context manager that yields a `(read, write)` pair
|
|||
* 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.
|
||||
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.
|
||||
|
|
|
|||
91
docs/deprecated.md
Normal file
91
docs/deprecated.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Deprecated features
|
||||
|
||||
The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**.
|
||||
|
||||
The table below names each deprecated feature, why it is going away, and the replacement to build on.
|
||||
|
||||
## What is deprecated
|
||||
|
||||
| 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](handlers/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](handlers/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](handlers/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](handlers/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](handlers/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
|
||||
|
||||
Nothing breaks today.
|
||||
|
||||
Every method above keeps working against any session that negotiated **2025-11-25 or earlier**. Pin `mode="legacy"` on the client and you get exactly the pre-2026 behaviour. There are no wire changes and capability negotiation is unchanged.
|
||||
|
||||
What changes is that you get a visible warning the first time each one runs:
|
||||
|
||||
```text
|
||||
MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
|
||||
```
|
||||
|
||||
`MCPDeprecationWarning` subclasses `UserWarning`, **not** `DeprecationWarning`. That is deliberate: Python's default filter only shows `DeprecationWarning` in code run directly as `__main__`, which is how libraries deprecate things and nobody notices for two years. This one shows up everywhere, with no `-W` flag.
|
||||
|
||||
!!! warning
|
||||
"Advisory" stops at the wire. Sampling and roots are server-to-client *requests*, and a
|
||||
2026-07-28 session has no channel to carry one. Call `ctx.session.create_message()`
|
||||
inside a tool on a modern connection and the warning still fires, and then the send
|
||||
fails with an error:
|
||||
|
||||
```text
|
||||
Cannot send 'sampling/createMessage': this transport context has no back-channel
|
||||
for server-initiated requests.
|
||||
```
|
||||
|
||||
Two signals, in that order. The `MCPDeprecationWarning` fires the moment you call the
|
||||
method, on any connection. The error is what comes back when the SDK then tries to
|
||||
send. These two only work end-to-end on a `mode="legacy"` connection whose client
|
||||
registered the matching callback.
|
||||
|
||||
## Silencing the warning
|
||||
|
||||
Don't, in new code.
|
||||
|
||||
But a server you maintain that genuinely serves pre-2026 clients has every right to a quiet log. Filter the category before the first deprecated call runs:
|
||||
|
||||
```python
|
||||
import warnings
|
||||
|
||||
from mcp import MCPDeprecationWarning
|
||||
|
||||
warnings.filterwarnings("ignore", category=MCPDeprecationWarning)
|
||||
```
|
||||
|
||||
That is the whole API. There is no per-method switch, and you don't want one: the point of one category is that one line silences it and one line brings it back.
|
||||
|
||||
!!! check
|
||||
Run the filter the other way and you get a free regression test. Add
|
||||
`"error::mcp.MCPDeprecationWarning"` to the `filterwarnings` setting in your pytest
|
||||
configuration and the deprecated call **raises** instead of warning. A tool named
|
||||
`old_log` that still calls `ctx.info()` stops passing and starts reporting:
|
||||
|
||||
```text
|
||||
Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
|
||||
```
|
||||
|
||||
One line of pytest configuration, and a deprecated call can never sneak back into your
|
||||
codebase without failing a test.
|
||||
|
||||
## Recap
|
||||
|
||||
* 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](handlers/multi-round-trip.md)** for sampling and roots, **[Logging](handlers/logging.md)** for logging, **[Progress](handlers/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.
|
||||
* New code should not be built on any of these.
|
||||
|
||||
Every other page in these docs teaches the current API.
|
||||
139
docs/get-started/first-steps.md
Normal file
139
docs/get-started/first-steps.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# First steps
|
||||
|
||||
The **[landing page](../index.md)** moves fast: write a server, run it, call a tool.
|
||||
|
||||
This page takes it slowly, with all three things a server can expose, and a name for everything along the way.
|
||||
|
||||
## Host, client, and server
|
||||
|
||||
Three words you'll see on every page from here on:
|
||||
|
||||
* A **host** is the LLM application: Claude, an IDE, an agent runtime. It's the thing the user is talking to.
|
||||
* A **client** lives inside the host and speaks MCP. The host runs one client per server it's connected to.
|
||||
* A **server** is what you build with this SDK. It exposes things to clients. It never talks to the model directly.
|
||||
|
||||
You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later on this page.
|
||||
|
||||
## The three primitives
|
||||
|
||||
A server exposes exactly three kinds of thing. What separates them is **who decides to use them**:
|
||||
|
||||
| Primitive | Controlled by | What it is | Example |
|
||||
|---------------|-----------------|-----------------------------------------------------|------------------------------------|
|
||||
| **Tools** | The model | A function the model calls to take an action | An API call, a database write |
|
||||
| **Resources** | The application | Data the host loads into the model's context | A file's contents, an API response |
|
||||
| **Prompts** | The user | A reusable message template the user invokes by name | A slash command, a menu entry |
|
||||
|
||||
"Controlled by" is the whole point of the split. A tool runs because the **model** decided to call it. A resource is attached because the **application** decided the model needed it. A prompt runs because the **user** picked it.
|
||||
|
||||
!!! info
|
||||
If you've built a web API you already have most of the intuition: a **resource** is a `GET`
|
||||
(it loads data and changes nothing) and a **tool** is a `POST` (it does work and may have
|
||||
side effects). A **prompt** has no HTTP analogue; it's closer to a saved query the user runs
|
||||
by name.
|
||||
|
||||
## One server, all three
|
||||
|
||||
```python title="server.py" hl_lines="6 12 18"
|
||||
--8<-- "docs_src/first_steps/tutorial001.py"
|
||||
```
|
||||
|
||||
Three plain functions, three decorators. Each decorator is the entire registration:
|
||||
|
||||
* `@mcp.tool()` makes `add` a **tool**.
|
||||
* `@mcp.resource("greeting://{name}")` makes `greeting` a **resource template**: the `{name}` in the URI is the function's parameter.
|
||||
* `@mcp.prompt()` makes `summarize` a **prompt**. The string it returns becomes a user message.
|
||||
|
||||
Everything else (the name, the description, the argument schema) the SDK reads from the function itself: its name, its docstring, its type hints. You never declared any of it separately.
|
||||
|
||||
!!! tip
|
||||
The two halves of the SDK have two import paths: `from mcp import Client` and
|
||||
`from mcp.server import MCPServer`. There is no `from mcp import MCPServer`.
|
||||
|
||||
### Try it
|
||||
|
||||
Run it with the MCP Inspector:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
Open the URL it prints. The Inspector has one tab per primitive; walk through them in order.
|
||||
|
||||
**Tools.** One entry: `add`, described as *Add two numbers.* The form has a required integer field for `a` and another for `b`. Fill them in, call it, and the result is `3`. The Inspector built that form from `a: int, b: int`. So does every other client.
|
||||
|
||||
**Resources.** The *Resources* list is empty. `greeting` is under **Resource Templates**, because `greeting://{name}` has a parameter: there is no single resource to list until someone supplies a `name`. Give it `World` and read it:
|
||||
|
||||
```text
|
||||
Hello, World!
|
||||
```
|
||||
|
||||
**Prompts.** One entry: `summarize`, with a single required `text` argument. Get it with some text and you receive one message with `role: user` and your rendered string as the content. That's all a prompt is: a function that builds messages.
|
||||
|
||||
The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **[Running your server](../run/index.md)** is the page for that.
|
||||
|
||||
## Capabilities
|
||||
|
||||
You saw three tabs in the Inspector. How did it know there were three?
|
||||
|
||||
When a client connects, the server declares its **capabilities**: which families of requests it will answer. The client uses that declaration to decide what to even ask for. You never wrote it; `MCPServer` declares it for you.
|
||||
|
||||
Look at it yourself. The SDK's `Client` accepts the server object directly and connects to it **in memory** (no subprocess, no port):
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from mcp import Client
|
||||
|
||||
from server import mcp
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with Client(mcp) as client:
|
||||
print(client.server_capabilities.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
```text
|
||||
{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}}
|
||||
```
|
||||
|
||||
That dictionary is your server's declared **capabilities**. It's the first thing every connecting client learns:
|
||||
|
||||
| Capability | The client may now call |
|
||||
|-------------|------------------------------------------------------------|
|
||||
| `tools` | `tools/list`, `tools/call` |
|
||||
| `resources` | `resources/list`, `resources/templates/list`, `resources/read` |
|
||||
| `prompts` | `prompts/list`, `prompts/get` |
|
||||
|
||||
`MCPServer` serves all three primitives, so all three are always declared.
|
||||
|
||||
Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **[Completions](../servers/completions.md)** proves it.
|
||||
|
||||
!!! info
|
||||
`Client(mcp)` is the same in-memory client every example in these docs is tested with, and
|
||||
it's how you'll test yours. It gets a whole page: **[Testing](testing.md)**.
|
||||
|
||||
## What you did not write
|
||||
|
||||
Look back over this page. You wrote three small Python functions. You did **not** write:
|
||||
|
||||
* A JSON Schema. `a: int, b: int` *is* the schema for `add`.
|
||||
* A request handler. `tools/list`, `resources/read`, `prompts/get`: all served for you.
|
||||
* A capability declaration. `MCPServer` made it for you.
|
||||
* A line of protocol. The version negotiation, the JSON-RPC framing, the capability exchange: all of it happened inside `mcp dev` and `Client(mcp)`, and you never saw it.
|
||||
|
||||
That ratio is the whole point of the SDK.
|
||||
|
||||
## Recap
|
||||
|
||||
* A **host** is the LLM app, a **client** is its MCP-speaking half, a **server** is what you build.
|
||||
* Tools are **model**-controlled, resources are **application**-controlled, prompts are **user**-controlled.
|
||||
* One decorator per primitive: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Name, description, and schema come from the function.
|
||||
* A URI with a `{param}` makes a resource **template**, listed separately from concrete resources.
|
||||
* The server's **capabilities** are declared for you, and a client only asks for what a server declares.
|
||||
* `Client(mcp)` connects to the server object in memory: your test harness from day one.
|
||||
|
||||
Next up is **[Connect to a real host](real-host.md)**: this server inside Claude Desktop or an IDE, for real. Then **[Testing](testing.md)**: one page, one in-memory client, and you're never guessing whether it works. After that, each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**.
|
||||
52
docs/get-started/index.md
Normal file
52
docs/get-started/index.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Get started
|
||||
|
||||
New to MCP, or new to this SDK? Start here. These pages take you from nothing to a
|
||||
working, tested server: [install the SDK](installation.md), build your
|
||||
[first server](first-steps.md), [connect it to a real host](real-host.md), and
|
||||
[test it](testing.md) with an in-memory client.
|
||||
|
||||
## Run the code
|
||||
|
||||
All the code blocks can be copied and used directly: they are complete, working files.
|
||||
|
||||
To follow along, paste a block into a `server.py` and open it in the MCP Inspector:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
It is **HIGHLY encouraged** that you write (or copy) the code, edit it, and run it locally. Using it in your own editor is what really shows you the point: how little you write, the autocompletion, the type checks catching mistakes before you run anything.
|
||||
|
||||
## You will not be guessing
|
||||
|
||||
Every example in these docs is a complete file under [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) in the SDK's own repository, and every one of them is exercised by the SDK's test suite through an **in-memory client**:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from mcp import Client
|
||||
|
||||
from server import mcp
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add() -> None:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
assert result.structured_content == {"result": 3}
|
||||
```
|
||||
|
||||
No subprocess, no port, no transport. `Client(mcp)` connects to the server object directly.
|
||||
|
||||
If a change to the SDK breaks an example on one of these pages, CI goes red before the page does. The code you read here is the code that runs.
|
||||
|
||||
You'll use this yourself in [Testing](testing.md); it's how you test your own servers, too.
|
||||
|
||||
## Where to go next
|
||||
|
||||
Once you have a server running, the rest of these docs are a reference, not a course.
|
||||
Every page stands on its own, so jump straight to what you need:
|
||||
|
||||
* What a server exposes (tools, resources, prompts) is **[Servers](../servers/index.md)**.
|
||||
* What's available inside the functions you register is **[Inside your handler](../handlers/index.md)**.
|
||||
* Getting it in front of clients (stdio, HTTP, your existing FastAPI app) is **[Running your server](../run/index.md)**.
|
||||
* Building the other side, an application that *uses* MCP servers, is **[Clients](../client/index.md)**.
|
||||
49
docs/get-started/installation.md
Normal file
49
docs/get-started/installation.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Installation
|
||||
|
||||
The Python SDK is on PyPI as [`mcp`](https://pypi.org/project/mcp/). It requires **Python 3.10+**.
|
||||
|
||||
These docs describe **v2**, which is in beta, so the version pin is not optional yet:
|
||||
|
||||
=== "uv"
|
||||
|
||||
```bash
|
||||
uv add "mcp[cli]==2.0.0b1"
|
||||
```
|
||||
|
||||
=== "pip"
|
||||
|
||||
```bash
|
||||
pip install "mcp[cli]==2.0.0b1"
|
||||
```
|
||||
|
||||
!!! warning "Why the pin"
|
||||
Installers never select a pre-release unless you name one, so an unpinned `uv add "mcp[cli]"`
|
||||
gives you the latest **v1.x** release, which these docs do not describe. Check the
|
||||
[release history](https://pypi.org/project/mcp/#history) for the newest beta before you copy
|
||||
the line above.
|
||||
|
||||
The same applies to one-off commands: `uv run --with "mcp==2.0.0b1" ...`, not `uv run --with mcp ...`.
|
||||
|
||||
If your *package* depends on `mcp`, add a `<2` upper bound (for example `mcp>=1.27,<2`) before
|
||||
the stable v2 lands so the major version bump doesn't surprise you.
|
||||
|
||||
## What gets installed
|
||||
|
||||
You don't need to know any of this to use the SDK, but if you're wondering what each dependency is for:
|
||||
|
||||
* `mcp-types`: every protocol type (requests, results, content blocks) as its own package, versioned in lockstep with the SDK. Every `from mcp_types import ...` in these docs is this package.
|
||||
* [`anyio`](https://anyio.readthedocs.io/): the async runtime. The whole SDK is written against anyio, so it runs on either `asyncio` or `trio`.
|
||||
* [`pydantic`](https://docs.pydantic.dev/): what every `mcp_types` model is built on, plus all schema generation and validation.
|
||||
* [`pydantic-settings`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/): server configuration via `MCP_*` environment variables and `.env` files.
|
||||
* [`httpx`](https://www.python-httpx.org/) and [`httpx-sse`](https://pypi.org/project/httpx-sse/): the HTTP client behind the Streamable HTTP and SSE *client* transports.
|
||||
* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/), and [`python-multipart`](https://pypi.org/project/python-multipart/): the HTTP *server* transports.
|
||||
* [`jsonschema`](https://pypi.org/project/jsonschema/): validates a tool's structured output against its declared output schema.
|
||||
* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): OAuth token handling for authorization.
|
||||
* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): just the lightweight API, so the SDK's tracing middleware costs nothing unless you install an OpenTelemetry SDK and exporter yourself.
|
||||
* [`typing-extensions`](https://typing-extensions.readthedocs.io/) and [`typing-inspection`](https://pypi.org/project/typing-inspection/): modern typing features on Python 3.10.
|
||||
* [`pywin32`](https://pypi.org/project/pywin32/): Windows only, used for `stdio` subprocess management.
|
||||
|
||||
## Optional extras
|
||||
|
||||
* `mcp[cli]` adds [`typer`](https://typer.tiangolo.com/) and [`python-dotenv`](https://pypi.org/project/python-dotenv/) for the `mcp` command-line tool (`mcp dev`, `mcp run`, `mcp install`). You'll want this during development; you may not need it in a deployed server.
|
||||
* `mcp[rich]` adds [`rich`](https://rich.readthedocs.io/) for nicer server logs.
|
||||
182
docs/get-started/real-host.md
Normal file
182
docs/get-started/real-host.md
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# Connect to a real host
|
||||
|
||||
A **host** is the application your server ends up inside: Claude Desktop, Claude Code, an IDE. The host is what the user talks to. Inside it, an MCP **client** launches your server as a child process and speaks to it over that process's stdin and stdout.
|
||||
|
||||
Which means connecting to a host is one act: you tell it **the command that starts your server**. Everything on this page (two CLI commands, three JSON files) is a different place to put that same command.
|
||||
|
||||
## One server, every host
|
||||
|
||||
```python title="server.py" hl_lines="3 33-34"
|
||||
--8<-- "docs_src/real_host/tutorial001.py"
|
||||
```
|
||||
|
||||
Two tools and a resource, one file. Three things about that file matter to every host below:
|
||||
|
||||
* `mcp.run()` with no arguments starts a **stdio** server: it blocks, reads protocol messages on stdin, and writes them on stdout. That is the transport every host on this page speaks. The host starts your file as a child process and owns those two pipes, which is why connecting is only ever "here is the command". You never pick a port, and nothing listens on one.
|
||||
* `run()` is under `if __name__ == "__main__":`. Everything below **imports** this file rather than executing it, so an unguarded `run()` would start a server the moment anything loaded the module.
|
||||
* The server object is a module-level global named `mcp`. That's the name `mcp run` looks for (`server` and `app` also work). Call it something else and you name it explicitly: `mcp run server.py:bookshop`.
|
||||
|
||||
That is the last line of Python on this page. From here down it is all host configuration.
|
||||
|
||||
## The launch command
|
||||
|
||||
Every host below gets the same command:
|
||||
|
||||
```bash
|
||||
uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
|
||||
```
|
||||
|
||||
One command for all of them because `uv run --with` resolves the pinned SDK into a fresh environment on the spot: it works from any directory, needs no project and no virtual environment to activate, and always gets the exact `mcp` version these docs describe. That matters here more than anywhere else, because a host launches your server from *its* working directory with a near-empty environment, not from your shell.
|
||||
|
||||
It is also the command `mcp install` writes into Claude Desktop's config for you (below), so what you type by hand and what the tool generates agree.
|
||||
|
||||
!!! warning "The version pin is not optional"
|
||||
v2 of this SDK is in beta, and installers never select a pre-release unless you name one. An
|
||||
unpinned `--with "mcp[cli]"` gives you the latest **v1.x**, which these docs do not describe.
|
||||
Use the exact pin from **[Installation](installation.md)**.
|
||||
|
||||
!!! tip "If a host can't find `uv`"
|
||||
A host spawns your server with a minimal `PATH`, and `uv` may not be on it. Replace the bare
|
||||
`uv` with the absolute path from `which uv` (macOS/Linux) or `where uv` (Windows). That is
|
||||
exactly what `mcp install` writes.
|
||||
|
||||
!!! note "This page is the local story"
|
||||
Everything here runs your server on the machine the host is on: the host launches your
|
||||
file, over stdio. That is exactly right for a personal or single-machine tool. To give a
|
||||
server to people who do *not* have your file, you hand out a **URL**, not a command: the
|
||||
same `mcp` object served over Streamable HTTP. **[Running your server](../run/index.md)**
|
||||
is that decision in one table, and **[Deploy & scale](../run/deploy.md)** is the road from
|
||||
there to a real hostname.
|
||||
|
||||
And a host is nothing more than an application with an MCP client inside it, so your own
|
||||
Python can play the host's part: **[Client transports](../client/transports.md)** launches
|
||||
this same file as a subprocess with `stdio_client(...)`, and **[Testing](testing.md)**
|
||||
connects to it in memory with no process at all.
|
||||
|
||||
## Claude Desktop
|
||||
|
||||
The one host the SDK can configure for you:
|
||||
|
||||
```bash
|
||||
uv run mcp install server.py
|
||||
```
|
||||
|
||||
That's it. `mcp install` imports the file to read the server's name, finds Claude Desktop's config file, and writes the launch command into it. Along the way it converts your path to an absolute one, so you don't have to.
|
||||
|
||||
There is nothing to be mystified by. This is the entry it writes:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"Bookshop": {
|
||||
"command": "/absolute/path/to/uv",
|
||||
"args": [
|
||||
"run",
|
||||
"--frozen",
|
||||
"--with",
|
||||
"mcp[cli]==2.0.0b1",
|
||||
"mcp",
|
||||
"run",
|
||||
"/absolute/path/to/server.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That's the launch command from the section above with two additions: the absolute path to `uv`, and `--frozen` so `uv` never rewrites a lockfile it happens to be near. It lands in `claude_desktop_config.json`, which lives at:
|
||||
|
||||
* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||
|
||||
You can write that file by hand. `mcp install` exists so you don't make the two classic mistakes (a relative path, a missing version pin) while doing it.
|
||||
|
||||
Fully quit Claude Desktop (not just its window) and reopen it.
|
||||
|
||||
!!! warning
|
||||
`mcp install` fails with `Claude app not found` if Claude Desktop's config *directory* doesn't
|
||||
exist yet. Install Claude Desktop and run it once: that's what creates the directory.
|
||||
|
||||
!!! tip
|
||||
Claude Desktop starts your server in its own process, so your shell's environment variables are
|
||||
not there. `uv run mcp install server.py -v API_KEY=abc123` (or `-f .env`) records them in the
|
||||
entry's `env` field. `--name` overrides the entry name; it defaults to the server's `name`.
|
||||
|
||||
## Claude Code
|
||||
|
||||
There is no file to edit. Register the server with the `claude` CLI; everything after `--` is the launch command.
|
||||
|
||||
```bash
|
||||
claude mcp add bookshop -- uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
|
||||
```
|
||||
|
||||
Run `/mcp` inside a Claude Code session to confirm `bookshop` is connected and its tools are listed.
|
||||
|
||||
## Cursor
|
||||
|
||||
Create `.cursor/mcp.json` in your project root.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bookshop": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The same `command` plus `args`, under the same `mcpServers` key Claude Desktop uses. The server appears in Cursor's MCP settings with both tools listed.
|
||||
|
||||
## VS Code
|
||||
|
||||
Create `.vscode/mcp.json` in your project root.
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"bookshop": {
|
||||
"type": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two differences from Cursor's file, and they are the only two: the wrapper key is `servers`, not `mcpServers`, and each entry declares its `type`. Confirm the trust prompt, then **MCP: List Servers** in the Command Palette shows `bookshop` running.
|
||||
|
||||
!!! note
|
||||
You need VS Code 1.99 or later with the **GitHub Copilot** extension signed in (Copilot Free is
|
||||
enough), and Copilot Chat must be in **Agent** mode, because no other mode calls tools.
|
||||
|
||||
## It doesn't show up
|
||||
|
||||
Before you touch any host config, run the launch command yourself:
|
||||
|
||||
```bash
|
||||
uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
|
||||
```
|
||||
|
||||
Nothing prints, and it doesn't return. That silence is correct: a stdio server is waiting for a host to speak first on stdin (`Ctrl-C` to stop it). A traceback or an immediate exit is the real bug, and now you can read it instead of guessing at it through a host.
|
||||
|
||||
Once that command sits and waits, what's left is almost always one of three things:
|
||||
|
||||
* **A relative path.** The host launches your server from *its* working directory, not the one you registered from. `server.py` where `/absolute/path/to/server.py` is needed is the single most common failure. If the host can't find `uv` either, that path has to be absolute too.
|
||||
* **The host is still running its old config.** Hosts read their config at launch. Claude Desktop in particular has to be *fully quit* (not just its window closed) and reopened before an edit to `claude_desktop_config.json` takes effect.
|
||||
* **Something reached stdout.** On stdio, stdout *is* the protocol. One stray `print()` and the host reads a corrupt message and drops the connection. Log with the `logging` module, which writes to stderr. **[Logging](../handlers/logging.md)** has the whole story.
|
||||
|
||||
Claude Desktop keeps a log per server: `mcp-server-<NAME>.log` is your server's stderr, next to `mcp.log` for connections, under `~/Library/Logs/Claude` on macOS and `%APPDATA%\Claude\logs` on Windows.
|
||||
|
||||
For anything past those three, **[Troubleshooting](../troubleshooting.md)** is the page.
|
||||
|
||||
## Recap
|
||||
|
||||
* A **host** (Claude Desktop, an IDE) runs an MCP client that launches your server as a child process over stdio. Connecting means giving it one launch command.
|
||||
* That command is `uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py`: version-pinned, no venv to activate, works from any directory. The pin is mandatory while v2 is in beta.
|
||||
* **Claude Desktop** is the one host `mcp install` configures for you. It writes that same command (plus the absolute path to `uv`) into `claude_desktop_config.json`, so you never have to.
|
||||
* **Claude Code** is `claude mcp add bookshop -- <launch command>`. **Cursor** is `.cursor/mcp.json` under `mcpServers`. **VS Code** is `.vscode/mcp.json` under `servers`, each entry with a `type`.
|
||||
* Absolute paths everywhere, restart the host after editing its config, and never let anything but the SDK write to stdout.
|
||||
|
||||
Every host on this page connected to the same file, with the same command. What that file can *expose* is the rest of these docs: **[Tools](../servers/tools.md)**, **[Resources](../servers/resources.md)**, and every transport besides stdio in **[Running your server](../run/index.md)**.
|
||||
107
docs/get-started/testing.md
Normal file
107
docs/get-started/testing.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Testing
|
||||
|
||||
The Python SDK ships a `Client` class with an **in-memory transport**: pass it your server object and it connects to it directly.
|
||||
|
||||
No subprocess. No port. No transport at all. It's the same idea as FastAPI's `TestClient`.
|
||||
|
||||
## Basic usage
|
||||
|
||||
Let's assume you have a simple server with a single tool:
|
||||
|
||||
```python title="server.py"
|
||||
--8<-- "docs_src/testing/tutorial001.py"
|
||||
```
|
||||
|
||||
To run the test below you'll need two extra (development) dependencies:
|
||||
|
||||
=== "uv"
|
||||
|
||||
```bash
|
||||
uv add --dev pytest inline-snapshot
|
||||
```
|
||||
|
||||
=== "pip"
|
||||
|
||||
```bash
|
||||
pip install pytest inline-snapshot
|
||||
```
|
||||
|
||||
!!! info
|
||||
These docs assume you already know [`pytest`](https://docs.pytest.org/en/stable/).
|
||||
|
||||
[`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is what the test below
|
||||
uses to assert on the whole result object in one line. It records the output of a test as the
|
||||
`snapshot(...)` literal you see. If you'd rather not use it, drop the import and assert on the
|
||||
fields you care about (`result.content[0].text == "3"`) like in any other test.
|
||||
|
||||
Now the test:
|
||||
|
||||
```python title="test_server.py"
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp import Client
|
||||
from mcp_types import CallToolResult, TextContent
|
||||
|
||||
from server import mcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend(): # (1)!
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(): # (2)!
|
||||
async with Client(mcp, raise_exceptions=True) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_add_tool(client: Client):
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(type="text", text="3")],
|
||||
structured_content={"result": 3},
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
1. If you are using `trio`, return `"trio"` instead. See the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) for the details.
|
||||
2. The fixture yields a connected client. Every test that takes `client` gets a fresh in-memory connection to the same server.
|
||||
|
||||
There you go! You can now extend your tests to cover more scenarios.
|
||||
|
||||
## Why `raise_exceptions=True`?
|
||||
|
||||
Two different things can go wrong, and this flag only touches one of them.
|
||||
|
||||
An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with
|
||||
`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or
|
||||
without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it:
|
||||
**[Handling errors](../servers/handling-errors.md)**.
|
||||
|
||||
A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the
|
||||
server sanitises it into a generic `"Internal server error"` before the client sees it. You should
|
||||
never leak the details of an unexpected crash to a remote caller. In a test that is exactly what
|
||||
you *don't* want, and it is what `raise_exceptions=True` changes: your test sees the real message
|
||||
instead of the sanitised one.
|
||||
|
||||
Leave it on in tests. It has no meaning in production code.
|
||||
|
||||
## In-process by default
|
||||
|
||||
!!! note
|
||||
`Client(mcp)` connects in-process and is **era-neutral** by default: it probes the server and
|
||||
picks the appropriate protocol path. Pin `mode="legacy"` if your test exercises legacy-specific
|
||||
semantics (sampling or elicitation push, `message_handler`), and drop `raise_exceptions=True`
|
||||
there: a legacy connection never sanitises in the first place, and the flag re-raises the
|
||||
failure inside the server task instead of in your test.
|
||||
|
||||
That one line is also why these docs can promise you that their examples work: every
|
||||
example file is exercised by the SDK's own test suite, almost all of them through exactly this
|
||||
client. You're using the same tool the SDK uses on itself.
|
||||
|
||||
You have a working, tested server. Putting it inside a real application (Claude Desktop, an
|
||||
IDE) is **[Connect to a real host](real-host.md)**; every other way to serve it is
|
||||
**[Running your server](../run/index.md)**.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue