Rebuild the docs around tested examples; shrink README.v2.md to a pitch (#2978)

This commit is contained in:
Max 2026-06-26 12:49:19 +02:00 • committed by GitHub
commit 411a6d3980
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
198 changed files with 9510 additions and 2639 deletions

View file

@ -7,6 +7,9 @@ on:
- v1.x
paths:
- docs/**
# docs pages include their code blocks from these files via `--8<--`, so a
# change here changes the rendered site even when no .md file moves.
- docs_src/**
- mkdocs.yml
- src/mcp/**
- scripts/build-docs.sh

View file

@ -110,3 +110,26 @@ jobs:
- name: Check README snippets are up to date
run: uv run --frozen scripts/update_readme_snippets.py --check --readme README.v2.md
# `mkdocs.yml` sets `strict: true` and `pymdownx.snippets: check_paths: true`,
# but until this job existed the docs were only ever built post-merge by
# `deploy-docs.yml`, so a broken link, a missing nav target, or a deleted
# `docs_src/` include went green on the PR and broke the next deploy of main.
# This is the check path; `deploy-docs.yml` stays the deploy path.
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
version: 0.9.5
- name: Install dependencies
run: uv sync --frozen --all-extras --python 3.10
- name: Build the docs in strict mode
run: uv run --frozen --no-sync mkdocs build --strict

View file

@ -65,5 +65,5 @@ repos:
name: Check README snippets are up to date
entry: uv run --frozen python scripts/update_readme_snippets.py --check
language: system
files: ^(README\.v2\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$
files: ^(README\.v2\.md|docs_src/.*\.py|examples/.*\.py|scripts/update_readme_snippets\.py)$
pass_filenames: false

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,121 @@
# Authorization
Over Streamable HTTP your MCP server is an ordinary web service, and you protect it the way you protect any web service: with OAuth 2.1 bearer tokens.
In OAuth terms, your server is a **resource server**. It never signs anyone in and it never issues a token. It does one thing: look at the `Authorization` header on each request and decide whether the token in it is good.
## The three parties
* The **authorization server** signs people in and issues access tokens. You don't write this. It's your identity provider (Auth0, Keycloak, Entra, your own).
* The **resource server** is your MCP server. It verifies the token on every request.
* The **client** discovers which authorization server you trust, gets a token from it, and sends it back to you as `Authorization: Bearer <token>`.
That's the whole triangle. Everything on this page is the middle bullet.
## A token verifier
The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**:
```python title="server.py" hl_lines="12-14 19-24"
--8<-- "docs_src/authorization/tutorial001.py"
```
* `TokenVerifier` is a protocol with one async method. `verify_token` gets the raw token from the `Authorization` header and returns an **`AccessToken`** if it's valid, `None` if it isn't. There is nothing else to implement.
* This one looks the token up in a table. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint. That code is yours; the SDK only calls it.
* `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request.
`AuthSettings` is the public face of your resource server:
* `issuer_url`: the authorization server that issues your tokens.
* `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives.
* `required_scopes`: every token must carry all of them.
!!! tip
`examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls
a real authorization server's RFC 7662 endpoint. It's the shape most production verifiers take.
## What you get over HTTP
Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: `mcp.run(transport="streamable-http")` puts it on `http://127.0.0.1:8000/mcp`, and **Running your server** has the rest. The app now has two routes:
```text
/mcp
/.well-known/oauth-protected-resource/mcp
```
You registered one tool. The second route is the SDK's.
### Discovery
`GET` that well-known path and you get **RFC 9728 Protected Resource Metadata**, built straight from your `AuthSettings`:
```json
{
"resource": "http://127.0.0.1:8000/mcp",
"authorization_servers": ["https://auth.example.com/"],
"scopes_supported": ["notes:read"],
"bearer_methods_supported": ["header"]
}
```
This document is how a client that has never heard of your server finds its way in: it reads `authorization_servers` and goes there for a token. You wrote none of it.
!!! check
Call `/mcp` with no token (or with one your verifier returned `None` for) and the request is
stopped at the door:
```text
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
{"error": "invalid_token", "error_description": "Authentication required"}
```
Nothing was parsed and no tool ran. And that `resource_metadata` pointer in `WWW-Authenticate` is
what makes discovery automatic: 401 -> metadata document -> authorization server -> token -> retry.
!!! warning
None of this protects `stdio`. A pipe has no `Authorization` header, so `token_verifier` is never
consulted there. A `stdio` server's security boundary is the process that launched it. The same
goes for the in-memory `Client(mcp)` you use in tests: it connects straight to the server object
and skips the HTTP layer, authorization included.
## The caller's identity
Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request:
```python title="server.py" hl_lines="4 32-35"
--8<-- "docs_src/authorization/tutorial002.py"
```
* It works in tools, resources, and prompts, and there is nothing to pass around: the auth middleware stores it in a context variable per request.
* You get back the **same object your verifier built**: `client_id`, `scopes`, `subject`, `expires_at`, and any extra `claims` you attached. That's the hook for per-tool rules: read the scopes and refuse.
* Outside an authenticated HTTP request it returns `None`. In-memory and over `stdio` it is always `None`.
Call `whoami` with `Authorization: Bearer alice-token` and the model reads:
```text
alice (scopes: notes:read)
```
## The half the SDK doesn't do
The SDK gives you the resource-server half: verify, advertise, refuse. It does not give you a login page, a consent screen, or a token.
To watch all three parties move, run `examples/servers/simple-auth/` from the SDK repository (a small authorization server and a resource server set up exactly like this page) and then point `examples/clients/simple-auth-client/` at it for the full discovery-and-token dance.
!!! info
There is a second constructor argument, `auth_server_provider=`, that embeds a full authorization
server inside your MCP server. It predates the AS/RS separation that the MCP authorization spec
is built around. New servers should not reach for it.
## Recap
* Over Streamable HTTP your server is an OAuth 2.1 **resource server**: it verifies tokens, it never issues them.
* `TokenVerifier` is the whole integration surface: one async method, token in, `AccessToken | None` out.
* `token_verifier=` and `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` always travel together.
* The SDK publishes RFC 9728 Protected Resource Metadata at `/.well-known/oauth-protected-resource/...` and answers unauthenticated requests with a 401 whose `WWW-Authenticate` header points at it. That is the entire discovery story.
* `get_access_token()` in any handler is who's calling.
* Authorization is an HTTP concern. `stdio` and the in-memory client never see it.
The other side of the handshake, a client that discovers your authorization server and fetches the token for you, is **OAuth clients**.

View 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 that matter as ordinary tool arguments or resource URIs. |
| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **Multi-round-trip requests**). |
| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **Logging**). |
| **`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**). |
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 the two places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **Multi-round-trip requests**.
* `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), restricts **progress** to server-to-client, and removes **`ping`**.
* The replacement column points you onward: **Multi-round-trip requests** for sampling, **Logging** for logging, **Progress** for progress. Roots needs no chapter (pass the paths as arguments) and `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.

View file

@ -0,0 +1,198 @@
# The low-level Server
`@mcp.tool()` is a layer. Underneath it is a second server class, `Server`, that speaks raw MCP: you hand it the protocol objects and it puts them on the wire, unchanged.
`MCPServer` is built on top of it. You drop down when the convenience layer is in the way:
* You need to emit an **exact** schema (loaded from a file, generated from a database), not one derived from a Python signature.
* You need full control of the result: `_meta`, `is_error`, every key of `structured_content`.
* You need to handle a method MCP doesn't define.
For everything else, stay on `MCPServer`.
## The same tool, by hand
This is `search_books` from **Tools** (the nine-line `@mcp.tool()` file) with the sugar removed:
```python title="server.py" hl_lines="23 27 33"
--8<-- "docs_src/lowlevel/tutorial001.py"
```
Three things changed, and they are the whole low-level API:
* **Handlers are constructor parameters.** `on_list_tools=` and `on_call_tool=` go into `Server(...)`. There are no decorators down here, and every handler has the same shape: `async (ctx, params) -> result`.
* **You write the input schema.** `Tool.input_schema` is a plain JSON Schema `dict`. Nobody derives it from type hints, because there are no type hints to derive it from.
* **You build the result.** `CallToolResult(content=[TextContent(...)])`, by hand. Nothing is wrapped, converted, or inferred from a return annotation.
`params` is the parsed request: `CallToolRequestParams` gives you `.name` and `.arguments`. `ctx` is a `ServerRequestContext`: `ctx.session` for talking back to the client, `ctx.lifespan_context`, `ctx.request_id`, and `ctx.meta`, the request's inbound `_meta`.
!!! info
If you've used FastAPI, you already know this relationship. `MCPServer` is the decorators-and-type-hints layer; `Server` is the Starlette underneath. They are not rivals: `MCPServer` constructs a `Server` and registers handlers exactly like these on it.
### Try it
There is no Inspector for this one: `mcp dev` and `mcp run` only accept an `MCPServer`. The in-memory `Client` doesn't care; it takes a low-level `Server` exactly like it takes an `MCPServer`:
```python title="main.py"
import asyncio
from mcp import Client
from server import server
async def main() -> None:
async with Client(server) as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
print(result.content)
asyncio.run(main())
```
```text
[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)]
```
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.
* `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** you saw a bad argument get rejected before your function ran. That was `MCPServer` validating the call against the schema it generated.
`Server` does not do that. Your `input_schema` is *advertised* to the client; it is never *applied* to `params.arguments`.
!!! check
Call `search_books` without `limit` and your `args["limit"]` raises `KeyError`. The client sees:
```text
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**.)
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**.
## Two tools, one handler
`on_call_tool` is the single entry point for every tool on the server. You route on `params.name`:
```python title="server.py" hl_lines="39-44"
--8<-- "docs_src/lowlevel/tutorial002.py"
```
* `list_tools` advertises both. `call_tool` dispatches on the name.
* The `else` branch matters: `Server` will happily forward a `tools/call` for a name you never listed straight into your handler. Raising there turns the call into the same `-32603` as above.
## Structured output, by hand
Declare `output_schema` on the `Tool` and put `structured_content` on the result. Both are yours:
```python title="server.py" hl_lines="20-24 37"
--8<-- "docs_src/lowlevel/tutorial003.py"
```
Call it and the result carries both representations:
```json
{
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
"structuredContent": {"matches": 3, "query": "dune"},
"isError": false,
"resultType": "complete"
}
```
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**.
## `_meta`: for the application, not the model
`content` is the part of the answer the model reads. `structured_content` is the same answer as typed data. `_meta` is the third channel: data that rides along with the result for the **client application**, without being part of the answer at all.
Use it for record IDs, trace IDs, anything your UI needs and your prompt doesn't:
```python title="server.py" hl_lines="38"
--8<-- "docs_src/lowlevel/tutorial004.py"
```
* You construct it as `_meta=`, the wire name. The client reads it back as `result.meta`.
* Namespace your keys (`bookshop/record_ids`). The `io.modelcontextprotocol/*` keys are reserved by the protocol.
!!! warning
`_meta` is a convention between you and the client application, not a guarantee about what reaches
the model. The host decides what it renders. Never put a secret in any part of a tool result.
## Capabilities follow your handlers
A `Server` advertises exactly the method families you gave it handlers for. The `Bookshop` above passes `on_list_tools` and `on_call_tool` and nothing else, so a client connecting to it sees:
```json
{"tools": {"listChanged": false}}
```
No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_prompts` and `prompts` appears; pass `on_completion` and `completions` appears.
`MCPServer` always advertises tools, resources and prompts, whether you registered any or not, because its managers always exist. Down here the declaration *is* the constructor call.
## The lifespan generic
`Server` is generic in the type its lifespan yields. Annotate it once and the object is typed everywhere it surfaces:
```python title="server.py" hl_lines="25-27 45-46 51"
--8<-- "docs_src/lowlevel/tutorial005.py"
```
* 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**.
Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`.
## A method of your own
The constructor covers the methods MCP defines. `add_request_handler` covers everything else:
```python title="server.py" hl_lines="35-36 39-40 43-44 48"
--8<-- "docs_src/lowlevel/tutorial006.py"
```
* The first argument is the method string. Notifications have a twin, `add_notification_handler`.
* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's.
* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result.
One honest caveat: the high-level `Client` only has verbs for the methods MCP defines, so there is no `client.reindex()`. A vendor method is for a peer that already knows it exists: a client you also ship, or another service of yours speaking JSON-RPC.
One method you cannot claim:
```text
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
```
The handshake belongs to the runner. `server/discover`, `ping`, and every other built-in are yours to replace.
!!! tip
`Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **Middleware**.
## The other handlers
Each of these is one idea you now have the vocabulary for; each has its own chapter.
* `on_call_tool` may return an `InputRequiredResult` instead of a `CallToolResult` to pause the call and ask the client for input; see **Multi-round-trip requests**.
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **Running your server** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.
## Recap
* The low-level `Server` takes its handlers as `on_*` **constructor parameters**; every handler is `async (ctx, params) -> result`.
* You write the `input_schema` dict and you build the `CallToolResult`. Nothing is derived, wrapped, or validated for you.
* An exception in a handler is a `-32603` protocol error. A tool error the model can read is a `CallToolResult` with `is_error=True` that **you** return.
* `_meta` on the result is addressed to the client application, not the model.
* `Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`.
* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved.
* The capabilities a `Server` advertises are derived from which handlers you registered.
`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **Middleware**.

130
docs/advanced/middleware.md Normal file
View file

@ -0,0 +1,130 @@
# Middleware
A **middleware** is one async function that wraps every message your server receives.
You write it as `async (ctx, call_next)` and append it to `server.middleware`. That is the whole API.
!!! warning
`Server.middleware` is marked **provisional** in the source. The signature and semantics are
expected to change before v2 is final. Use it to *observe*: timing, logging, tracing.
Do not make it the foundation your server stands on.
This is a **low-level `Server`** feature. `MCPServer` does not expose a middleware list.
If `Server(name, on_call_tool=...)` is new to you, read **The low-level Server** first.
## A timing middleware
One server, one tool, one middleware that logs how long each message took:
```python title="server.py" hl_lines="40-46 50"
--8<-- "docs_src/middleware/tutorial001.py"
```
* `ctx` is the same `ServerRequestContext` your handlers receive. `ctx.method` is the raw
method string; `ctx.params` are the raw params, **before** any validation.
* `call_next(ctx)` runs the rest of the chain: validation, the handler lookup, your handler.
Return what it returned and the response is untouched.
* The `try`/`finally` is deliberate: a handler that raises is still timed, because the failure
reaches your middleware as the exception out of `call_next`.
* `server.middleware.append(...)` registers it. The list runs outermost-first, so
`middleware[0]` is the one closest to the wire.
### Try it
Connect a client, list the tools, call one. Your log has **three** lines:
```text
server/discover took 18.3 ms
tools/list took 0.1 ms
tools/call took 0.1 ms
```
You made two calls and got three lines. The first is `server/discover`: the request the
client sent to set the connection up, before you asked for anything.
That is the point. Middleware wraps **every** inbound message:
* The connection setup: `server/discover`, or `initialize` and `notifications/initialized`
on a legacy session.
* Every request and every notification. For a notification, `ctx.request_id is None`,
`call_next(ctx)` returns `None`, and whatever you return is discarded.
* Even a method the server has no handler for: `call_next` raises the
`MCPError(-32601, "Method not found")` *through* your middleware on its way to the client.
## What you can do inside one
In increasing order of how much you should hesitate:
* **Observe.** Time it, count it, log it. The example above.
* **Refuse.** Raise an `MCPError` *instead of* calling `call_next(ctx)` and that one message is
answered with a JSON-RPC error. The connection stays up; the next message goes through.
* **Rewrite.** `ctx` is a dataclass: `await call_next(dataclasses.replace(ctx, params=...))`
hands the rest of the chain different params than the client sent. Never do this to
`initialize`: the result the client gets back is built from your rewritten params, but the
server commits its connection state from the original wire params. The two sides can finish
the handshake disagreeing about what they negotiated.
!!! check
`initialize` is one of the things middleware wraps, and it is the *only* hook you get
for it. Try to take it over with `add_request_handler` and the SDK refuses:
```text
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
```
!!! warning
`initialize` is handled inline: the server reads no further inbound messages until your
middleware chain returns. Awaiting a server-to-client request (`ctx.session.send_request(...)`,
an elicitation) while handling `initialize` therefore **deadlocks the connection**: the
response you are waiting for can never be read. Fire-and-forget notifications are fine.
## `OpenTelemetryMiddleware`
The SDK ships one middleware: `OpenTelemetryMiddleware`. Construct it and append it
(`server.middleware.append(OpenTelemetryMiddleware())`), exactly the line you already wrote
for `log_timing`.
Every inbound message becomes a `SERVER` span named after the method and its target, so a
`tools/call` for `search_books` is the span `tools/call search_books`.
* Every span carries `mcp.method.name` and `mcp.protocol.version`; a request's span also
carries its JSON-RPC request id (a notification has none).
* A `tools/call` span gets OpenTelemetry's GenAI semantic conventions,
`gen_ai.operation.name` (`"execute_tool"`) and `gen_ai.tool.name`, so a tracing UI groups
your tool calls the way it groups any other agent's. A `prompts/get` span gets
`gen_ai.prompt.name`. The list methods carry no `gen_ai.*` keys.
* A handler that raises sets the span's status to error. So does a tool result with
`is_error=True`.
!!! tip
The SDK depends only on `opentelemetry-api`. With no exporter installed those spans are
no-ops, so appending this middleware costs you nothing. Install `opentelemetry-sdk` plus an
exporter and everything lights up, with no server change.
The import is the catch. The class lives at `from mcp.server._otel import OpenTelemetryMiddleware`
today, and the leading underscore is not an accident: it is the same provisional flag this whole
page opened with. The SDK has not given it a public spelling yet, so the import path is the one
line here you should expect to change.
!!! info
If you have written ASGI middleware, you already know this shape. Starlette's
`(scope, receive, send)` became `(ctx, call_next)`, and it runs *after* the transport, on
the decoded message instead of the raw HTTP request. The two compose: Starlette middleware
on `streamable_http_app()` sees HTTP; this sees MCP.
## Recap
* A middleware is `async (ctx, call_next) -> result`, appended to `server.middleware` on the
low-level `Server`.
* It wraps **every** inbound message (`server/discover`, `initialize`, requests, notifications,
unknown methods) and runs outermost-first.
* `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.
* `OpenTelemetryMiddleware` turns each message into a span (with GenAI attributes on tool
calls and prompt gets) for the price of one `append`, and costs nothing until you install
an exporter.
* The whole surface is provisional. Observe with it; don't build on it.
That is everything that wraps a request. **Authorization** is what decides whether the request
gets to run at all.

View file

@ -0,0 +1,96 @@
# Multi-round-trip requests
Sometimes a tool can't finish in one round trip. It needs something only the user has: a choice, a confirmation, a credential.
Before 2026-07-28 the server got it by calling **back**: opening its own request to the client (an elicitation, a sampling call) in the middle of handling the original one. The 2026-07-28 spec retires that back-channel.
Instead, the server **returns**.
## Return, don't call back
The server answers `tools/call` with an **`InputRequiredResult`** instead of a `CallToolResult`. Two of its fields do the work:
* **`input_requests`**: what the server still needs, as a dict keyed by names the server chose. Each value is an `ElicitRequest`, a `CreateMessageRequest`, or a `ListRootsRequest`.
* **`request_state`**: an opaque token. The client echoes it back verbatim on the retry. Your server is the only thing that reads it.
The client fulfils each request, then calls the **same tool again**, carrying its answers in `input_responses` and the token in `request_state`. The server now has what it was missing and returns a normal `CallToolResult`.
That's the whole protocol. Every leg is an ordinary request from the client to the server. Nothing ever flows the other way.
## The server side
The high-level `@mcp.tool()` decorator has no sugar for this yet. Today you write it on the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type:
```python title="server.py" hl_lines="44-47"
--8<-- "docs_src/mrtr/tutorial001.py"
```
* `on_call_tool` is typed `-> CallToolResult | InputRequiredResult`. Returning the second one is the entire server-side API.
* On the first call `params.input_responses` is `None`, so the guard fires and the handler asks instead of answering.
* On the retry, the `ElicitResult` the client sent is sitting under the **same key** (`"region"`) that the server used in `input_requests`.
Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **The low-level Server**. This page only adds the second return type.
## The client side
`call_tool` will not hand you an `InputRequiredResult` unless you opt in.
!!! check
Call a tool that needs input without opting in and `call_tool` raises:
```text
Server returned InputRequiredResult; pass allow_input_required=True to receive it and retry call_tool(..., input_responses=..., request_state=result.request_state).
```
That is deliberate. Most call sites expect a result or an exception, not a third thing in the
middle of the happy path, and pyright agrees: without the flag, `call_tool` is typed to return
a plain `CallToolResult`.
Pass `allow_input_required=True` and the result reaches you intact:
```python
result.result_type # 'input_required'
result.request_state # 'provision-v1'
result.input_requests # {'region': ElicitRequest(method='elicitation/create', params=ElicitRequestFormParams(...))}
```
### The retry loop
Now you own the loop. There is no automatic driver yet; `while isinstance(result, InputRequiredResult)` **is** the API:
```python title="client.py" hl_lines="13-15 17-20"
--8<-- "docs_src/mrtr/tutorial002.py"
```
* `allow_input_required=True` widens the return type to `CallToolResult | InputRequiredResult`. That union is exactly what the `isinstance` is narrowing.
* For every entry in `input_requests` you put an `InputResponse` under the **same key** in `input_responses`. `fulfil` is where your UI goes; this one hard-codes the answer.
* Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method.
* `request_state=result.request_state`: copy it across. Never inspect it, never invent it.
* When the server has everything it needs it returns a `CallToolResult` and the loop exits.
## A 2026-07-28 result
`InputRequiredResult` only exists at protocol version **2026-07-28**. The in-memory `Client(server)` negotiates it for you; over the wire, `mode="auto"` discovers it. After connecting, `client.protocol_version` tells you what you got.
!!! warning
A pre-2026 session has nowhere to put an `InputRequiredResult`. Return one from your handler on a
`mode="legacy"` connection and the runner cannot serialize it into the negotiated version; the
client gets back a `-32603` *"Handler returned an invalid result"* error. A server that serves
both eras must check `ctx.protocol_version` before reaching for it.
!!! info
**URL-mode elicitation** rides this exact mechanism on a 2026 connection. The entry in
`input_requests` is an `ElicitRequest` whose params are `ElicitRequestURLParams`; the user
finishes the out-of-band flow and your client retries the call. Same loop, no new API. The
high-level server half is in **Elicitation**.
## Recap
* At 2026-07-28 a server that needs input mid-call **returns** an `InputRequiredResult`. It never opens a request to the client.
* `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads.
* The client answers by calling the **same tool again** with `input_responses=` and `request_state=`.
* By default `call_tool` raises on an `InputRequiredResult`; `allow_input_required=True` opts in and widens the return type.
* The manual `while isinstance(result, InputRequiredResult)` loop is the whole client API; there is no auto-retry driver yet.
* The server side is the **low-level** `Server` only; `@mcp.tool()` has no sugar for this yet.
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **Deprecated features**.

View file

@ -0,0 +1,137 @@
# 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 chapter is the client side. Making your own server demand a token is **Authorization**.
## 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 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**.
## 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.
### Try it
Everything else in these docs you have checked 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 chapter'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.
## 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.
## 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, dynamic registration, 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**.

View file

@ -0,0 +1,80 @@
# Pagination
Most servers never need this.
`MCPServer` answers every `list_*` request with everything it has, in one page, `next_cursor=None`. For a few dozen tools, resources or prompts that is the right answer and there is nothing to configure.
Pagination is for the server whose resource list is really a database: thousands of rows it refuses to serialize in one response. The protocol's answer is a **cursor**: the server returns a page plus an opaque token, and the client sends that token back to get the next page.
`@mcp.resource()` has no hook for any of that. To page, you write the list handler yourself, on the **low-level Server**.
## A server that pages
```python title="server.py" hl_lines="13 16-17"
--8<-- "docs_src/pagination/tutorial001.py"
```
* On a low-level `Server`, handlers are constructor arguments, not decorators. `on_list_resources` answers every `resources/list` request; that's the whole hookup.
* Every paged handler is typed `params: PaginatedRequestParams | None`, and the example accepts both. Over a connection, though, the SDK never hands you `None` (a request with no `params` member reaches the handler as the model with its defaults), so the signal that matters is `params.cursor is None`: **start from the top**.
* You decide what a cursor *is*. Here it's an offset rendered as a string. A timestamp, a primary key, a base64 blob: anything you can mint on the way out and recognise on the way back in.
* `next_cursor=None` is how you say "that was the last page". There is no count, no total, no `has_more`. `None` is the entire signal.
!!! tip
A `PAGE_SIZE` of 10 makes the example readable. Pick yours per endpoint: a list of
one-line resources can afford a page of 500; a list of fat prompt templates cannot.
The client has no say in it, and that is by design.
### Try it
`Client(server)` connects to a low-level `Server` in memory exactly as it connects to an `MCPServer`.
Call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`.
Hand it back with `list_resources(cursor="10")` and the first resource is `book-11`, the new `next_cursor` is `"20"`.
The tenth page comes back with `next_cursor` set to `None`. Done.
## The client loop
Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) takes a `cursor=` keyword. Draining a paged list is one `while True`:
```python title="client.py" hl_lines="27-33"
--8<-- "docs_src/pagination/tutorial002.py"
```
* `cursor` starts as `None`, so the first request carries no cursor.
* Extend **before** you look at `next_cursor`: the last page has resources too.
* `next_cursor is None` is the exit. Anything else goes straight back into `cursor=`, untouched.
Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages.
This is the same loop **The Client** chapter showed you, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once.
## The three rules
**Cursors are opaque.** A client must never parse, build, or guess one. The only legal source of a cursor is the previous page's `next_cursor`, verbatim.
**The server picks the page size.** There is no `limit=` in the protocol. If you need a different page size, you change the server.
**A client that ignores paging still works.** It calls `list_resources()` once, gets the first ten, and never notices the `next_cursor` it threw away. Nothing breaks; it sees less.
!!! check
Opaque means opaque. Invent a cursor (`list_resources(cursor="page-2")`) and there is
nothing the protocol can do for you. This server tries `int("page-2")`, the handler raises,
and what comes back to the client is:
```text
MCPError(-32603, 'Internal server error', None)
```
A cursor you didn't get from the server is a bug, not a feature request.
## Recap
* `MCPServer` returns everything in one page. Pagination is opt-in, and you opt in on the low-level `Server`.
* `on_list_resources` (and `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) receives `PaginatedRequestParams | None`; `params.cursor` is `None` for the first page.
* You return a page plus `next_cursor`: any string you'll recognise later, or `None` when there is nothing left.
* The client loop: pass `cursor=`, accumulate, repeat until `next_cursor is None`.
* Cursors are opaque, the server owns the page size, and a non-paging client still gets page one.
The rest of the hand-written `Server` API (`on_call_tool`, `input_schema` dicts, `_meta`) is **The low-level Server**.

View 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**. 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**.

View file

@ -1,5 +0,0 @@
# Authorization
!!! warning "Under Construction"
This page is currently being written. Check back soon for complete documentation.

143
docs/client/callbacks.md Normal file
View file

@ -0,0 +1,143 @@
# Client callbacks
So far every request has gone one way: client to server.
A server can also ask the **client** for things: to put a question to the user, to sample the user's model, to list the user's workspace folders. You answer those requests by passing **callbacks** to `Client(...)`.
## A server that asks
Here is a server whose tool can't finish on its own:
```python title="server.py" hl_lines="16"
--8<-- "docs_src/client_callbacks/tutorial001.py"
```
* `ctx.elicit(...)` sends an `elicitation/create` request **to the client** and waits.
* The tool doesn't return until somebody (a person in a form, or your code) supplies a `name`.
That is the server half, and the **Elicitation** chapter owns it. This chapter is the other end of the wire.
## The elicitation callback
```python title="client.py" hl_lines="7-11 17-18"
--8<-- "docs_src/client_callbacks/tutorial002.py"
```
* An elicitation callback is `async (context, params) -> ElicitResult`.
* `params.message` is the question. `params.requested_schema` is the JSON Schema of the answer the server wants. A real client renders a form from it; this one auto-fills.
* You return `ElicitResult(action="accept", content={...})`, or `action="decline"`, or `action="cancel"`. The only other option is `ErrorData(...)`, which refuses the request and fails the whole call.
* `context` is a `ClientRequestContext`: the live `session`, the server's `request_id`, and any `meta` it attached.
!!! tip
`params` is a union of the two elicitation modes. Here `params.mode` is `"form"`; a `"url"` request
carries `params.url` instead of a schema. One callback handles both; branch on `params.mode`.
**Elicitation** shows the full pattern.
### Try it
Call `issue_card` and watch both ends.
Your callback receives the server's question, already parsed:
```python
params.mode # 'form'
params.message # 'What name should go on the card?'
params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}},
# 'required': ['name'], 'title': 'CardHolder', 'type': 'object'}
```
It answers, `ctx.elicit(...)` resumes inside the tool, and the tool finishes:
```python
result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
```
One `tools/call` from you, one `elicitation/create` back from the server, answered by your function, all inside a single tool call.
!!! info
`mode="legacy"` on line 17 is doing real work. By default `Client(...)` negotiates the modern
protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit`
fails before your callback ever runs. The transport doesn't decide that; the negotiated
protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has
to answer one; every test behind this page does. **Protocol versions** has the whole story.
## A callback is a capability
You never told the server that your client can answer elicitation requests. The SDK did.
When a client connects it declares its `capabilities`, the mirror image of the server's. You don't write that object. **Registering a callback is the declaration.**
| you pass | the client declares |
| --- | --- |
| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` |
| `sampling_callback=` | `"sampling": {}` |
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
| none of them | `{}` |
`logging_callback` and `message_handler` are not in the table. They handle notifications, and notifications need no capability.
The server reads the declaration back with `ctx.session.check_client_capability(...)`. Add a tool that does:
```python title="server.py" hl_lines="23-31"
--8<-- "docs_src/client_callbacks/tutorial003.py"
```
Connect with only `elicitation_callback` and call it:
```python
result.structured_content # {'result': ['elicitation']}
```
Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pass none and you get `[]`.
!!! check
Now do the wrong thing: connect **without** `elicitation_callback` and call `issue_card` anyway.
The server's `elicitation/create` request still reaches your client, and the SDK answers it for
you, with an error, because you never said you could handle it. That error sinks the whole call.
`call_tool` doesn't return an `is_error` result; it raises:
```text
MCPError: Elicitation not supported
```
That is a protocol error (`-32600`, *invalid request*), not a tool error: there is nothing for
the model to read and retry. It's why `client_features` is worth having: a well-behaved server
checks before it asks.
## The deprecated pair
`sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in.
Both work. Both follow the rule above. And both serve features the **2026-07-28 spec deprecates**: a modern server doesn't call back into your model mid-request, it hands the request back to you as part of the tool result (**Multi-round-trip requests**), and roots give way to plain tool arguments and resource URIs. The whole list is in **Deprecated features**.
You still need the callbacks to talk to servers that haven't moved. The signatures:
```python title="client.py"
--8<-- "docs_src/client_callbacks/tutorial004.py"
```
* A sampling callback receives the full `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) and returns a `CreateMessageResult`. *You* run the model, however you like; the SDK only carries the request.
* A roots callback takes no params at all and returns a `ListRootsResult`.
* Either one may return `ErrorData(...)` instead, to refuse.
Pass them to `Client(...)` exactly like `elicitation_callback`.
## The notification callbacks
Two more. Neither declares anything.
`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**Logging** has what to do instead), so this callback exists for the servers that still emit it.
`message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing.
## Recap
* A server can send requests to the client. You answer them with callbacks passed to `Client(...)`.
* The elicitation callback is the current one: `async (context, params) -> ElicitResult`, one function for both form and URL mode.
* **Registering a callback is declaring the capability.** Without it, the SDK refuses the server's request on your behalf and the whole call fails with `MCPError`.
* A server finds out before asking with `ctx.session.check_client_capability(...)`.
* `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead.
* `logging_callback` and `message_handler` receive notifications. They declare nothing.
Next: the first argument you've been passing to `Client(...)` all along, **Client transports**.

212
docs/client/index.md Normal file
View file

@ -0,0 +1,212 @@
# The Client
A **`Client`** is how a Python program talks to an MCP server.
It is one object with one lifecycle: construct it, enter `async with`, call methods. Every protocol verb (list the tools, call one, read a resource, render a prompt) is an `async` method on it that returns a typed result.
## Your first client
```python title="client.py" hl_lines="14-18"
--8<-- "docs_src/client/tutorial001.py"
```
The server at the top is only there so you have something to connect to. The client is the five highlighted lines.
* `Client(mcp)` is given the **server object itself**. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example in this chapter, and every test you write, connects.
* `async with` is the **lifecycle**. Entering it connects and negotiates; leaving it disconnects. There is no `connect()` / `close()` pair, and a `Client` cannot be reused after the block ends.
* Inside the block the connection facts are already there as plain properties.
### What you can pass to `Client`
`Client` takes one positional argument and resolves the transport from its type:
* An `MCPServer` (or low-level `Server`) instance: connected **in-process**.
* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path.
* A **transport**: anything you can `async with ... as (read, write)`, such as `stdio_client(...)` wrapping a subprocess.
Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own chapter: **Client transports**.
### What's on a connected client
Four read-only properties, populated the moment you enter the block:
* `client.server_info`: the server's identity. `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports.
* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`.
* `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`.
* `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one.
You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **Protocol versions** has the whole story.
!!! tip
`client.session` is the underlying `ClientSession`, the low-level escape hatch.
You won't need it for anything on this page.
## Listing tools
```python title="client.py" hl_lines="15-20"
--8<-- "docs_src/client/tutorial002.py"
```
`list_tools()` returns a `ListToolsResult`; the tools are in `.tools`. Each one is the complete definition a host would hand to a model:
```python
tool.name # 'search_books'
tool.title # 'Search the catalog'
tool.description # 'Search the catalog by title or author.'
```
and `tool.input_schema` is the JSON Schema the server derived from the function's type hints:
```json
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"default": 10, "title": "Limit", "type": "integer"}
},
"required": ["query"],
"title": "search_booksArguments"
}
```
That schema is everything a UI needs to render an argument form, and everything a model needs to produce valid arguments.
!!! tip
`title` is optional, so a UI showing tools to a human has to pick: the `title` if there is one,
the `name` if not. `from mcp.shared.metadata_utils import get_display_name` does exactly that,
for tools, resources, resource templates and prompts.
## Calling a tool
`call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`.
```python title="client.py" hl_lines="26-33"
--8<-- "docs_src/client/tutorial003.py"
```
The server's `lookup_book` returns a Pydantic `Book`. Here is what the client sees:
```python
result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')]
result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965}
result.is_error # False
```
One return value, three things to read. Each has a different consumer.
### `content`: what the model reads
`content` is a `list` of **content blocks**, and a content block is a union: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, or `EmbeddedResource`. A tool can return several, of different kinds.
That is why `main` narrows with `isinstance(block, TextContent)` before touching `block.text`. Notice there is no `.text` outside the `isinstance`: the type checker won't allow it, because `ImageContent` has `.data`, not `.text`. The union is honest about what a tool is allowed to send you; your code should be too.
### `structured_content`: what your application reads
`structured_content` is the tool's return value as JSON, matching the tool's declared `output_schema`. No string parsing, no guessing.
When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **Structured Output** chapter.
### `is_error`: whether the tool failed
A tool that raises does **not** raise in your client. It comes back as an ordinary result with `is_error=True`.
!!! check
Ask `lookup_book` for `"Solaris"` (a title that isn't in the catalog) and the function raises
`ValueError`. The call still returns normally:
```python
result.is_error # True
result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
result.structured_content # None
```
The exception's message landed in `content`, where the **model** can read it and try again. That
is deliberate: a tool error is part of the conversation, not a crash. Always look at `is_error`
before you trust `structured_content`.
!!! warning
`is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have
(`call_tool("does_not_exist", {})`) and nothing raises. You get the same shape back,
`is_error=True` with `Unknown tool: does_not_exist` in `content`. A `Client` method raises
`MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and
**Handling errors** covers when a server produces which.
## Resources
The resource verbs come in pairs: two ways to list, one way to read.
```python title="client.py" hl_lines="23-32"
--8<-- "docs_src/client/tutorial004.py"
```
* `list_resources()` returns the **concrete** resources, the ones with a fixed URI. Here: `['catalog://genres']`.
* `list_resource_templates()` returns the **parameterised** ones. Here: `['catalog://genres/{genre}']`. They are two different lists because a template isn't readable until you fill it in.
* `read_resource(uri)` takes a plain `str` URI and works on both: pass `"catalog://genres/poetry"` and the server matches it to the template.
`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`).
A client can also **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. A server that does support subscriptions is built on the low-level `Server` (**The low-level Server**).
## Prompts
```python title="client.py" hl_lines="15-20"
--8<-- "docs_src/client/tutorial005.py"
```
`list_prompts()` tells you what the server offers and what each prompt needs:
```python
prompt.name # 'recommend'
prompt.title # 'Recommend a book'
prompt.arguments # [PromptArgument(name='genre', required=True)]
```
`get_prompt(name, arguments)` renders it. The arguments dict is `str -> str`: prompt arguments are always strings. The result is `messages`, a list of `PromptMessage`, each with a `role` and a `content` block:
```python
message.role # 'user'
message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')
```
A host hands those messages straight to the model. That is the whole feature.
## Completions
A server with a completion handler can autocomplete prompt and resource-template arguments as the user types.
```python title="client.py" hl_lines="28-32"
--8<-- "docs_src/client/tutorial006.py"
```
* `ref` says *which* prompt or template you're filling in: a `PromptReference` or a `ResourceTemplateReference`.
* `argument` is `{"name": ..., "value": ...}`: the argument and what the user has typed so far.
The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **Completions** chapter.
## Pagination
Every `list_*` method takes a `cursor=` keyword and every result carries a `next_cursor`. When `next_cursor` is `None`, you have everything.
```python title="client.py" hl_lines="23-31"
--8<-- "docs_src/client/tutorial007.py"
```
This loop is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **Pagination**.
## In tests
`Client(mcp)` with no process and no port is already a test harness for your server.
There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **Testing** is the chapter that explains it and builds the whole pattern around it.
## Recap
* `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport.
* `async with` is the whole lifecycle. Inside it, `server_info`, `server_capabilities`, `protocol_version` and `instructions` are already populated.
* `list_tools()` gives you each tool's `name`, `title`, `description` and `input_schema`.
* `call_tool()` returns `content` for the model, `structured_content` for your code, and `is_error`. A raising tool is a result, not an exception.
* `content` is a union of block types; narrow with `isinstance` before reading.
* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, and `complete` round out the verbs.
* Every `list_*` takes `cursor=`; loop until `next_cursor` is `None`.
Next: the things a server can ask the *client* for, and how you answer, in **Client callbacks**.

View file

@ -0,0 +1,127 @@
# Protocol versions
MCP has two eras.
Servers released before 2026-07-28 open every connection with the **`initialize` handshake**: the client proposes a version, the server counters, the client acknowledges, all before the first useful request. Servers at **2026-07-28** drop the handshake. The client sends one **`server/discover`** probe and the server answers it with everything in a single result.
You haven't had to care, because `Client` negotiates for you. This chapter is about the one constructor argument that controls it, `mode=`, and the three times you change it.
## `mode="auto"`
```python title="client.py" hl_lines="14-15"
--8<-- "docs_src/protocol_versions/tutorial001.py"
```
You didn't pass `mode`, so you got the default: `"auto"`. Entering `async with` sends a single `server/discover` probe at the newest version this SDK speaks. Then:
* A **modern server** answers it. The client adopts the result. One round trip, done.
* An **older server** has never heard of `server/discover` and returns an error. The client falls back to the classic `initialize` handshake and takes whatever that negotiates.
Either way you come out connected, and `client.protocol_version` tells you which it was:
```text
2026-07-28
```
That is the whole feature. One `Client`, any era of server, no branching in your code.
!!! info
`MCPServer` answers `server/discover`, so against your own in-memory server `auto` always lands
on `2026-07-28`. The fallback only ever fires against a real pre-2026 server, which is exactly
when you want it to.
## `mode="legacy"`
```python title="client.py" hl_lines="14"
--8<-- "docs_src/protocol_versions/tutorial002.py"
```
`mode="legacy"` never probes. It runs the `initialize` handshake, the same connection a pre-2026 client opens.
```text
2025-11-25
```
Same server. It speaks `2026-07-28` perfectly well; you told the client not to ask.
You want this for the **push-style** features.
A server-initiated request is the server calling *you*: `ctx.elicit(...)` putting a form in front of your user, sampling asking your model for a completion mid-tool-call. That channel only exists on a handshake-era session.
At 2026-07-28 it is gone. The server *returns* its questions and you retry the call with the answers (**Multi-round-trip requests**).
`mode="auto"` only gives you a handshake when the server is too old for anything else. `mode="legacy"` guarantees one. Reach for it whenever you hand `Client(...)` a `sampling_callback`, an `elicitation_callback` you want driven as a request, or a `message_handler`. **Client callbacks** goes through each.
## Pinning a version
`mode` also accepts a modern protocol version string. Today that set is exactly `["2026-07-28"]`.
```python title="client.py" hl_lines="14"
--8<-- "docs_src/protocol_versions/tutorial003.py"
```
A pin sends **nothing**. No probe, no handshake. The client adopts `2026-07-28` locally and the connection is live the instant `async with` returns.
A pin is a promise *you* make: you already know the server speaks that version. The client doesn't check.
!!! check
A pin is not a discovery. Print `client.server_info` and the price is right there:
```text
name='' title=None version='' description=None website_url=None icons=None
```
The client never asked the server who it is, so `server_info` is a blank. `client.server_capabilities`
is the same story: every capability is `None`. Tool calls still work (the protocol needs none of it);
code that reads `server_capabilities` to decide what to offer does not.
The next section is the fix.
Only modern versions are pinnable. A handshake-era string is rejected at construction, before any I/O, and the error tells you what to write instead:
```text
ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy')
```
## Reconnecting with `prior_discover`
The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes.
So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `server_info`, its `instructions`. Hand it back as `prior_discover=` the next time:
```python title="client.py" hl_lines="15 17"
--8<-- "docs_src/protocol_versions/tutorial004.py"
```
```text
2026-07-28
Bookshop
```
The second connection made **zero** negotiation round trips and still knows exactly who it is talking to. That is the pinned mode done properly: `mode=` names the version, `prior_discover=` supplies the identity. ✨
`DiscoverResult` is a Pydantic model. `saved.model_dump_json()` goes into a file or a cache; `DiscoverResult.model_validate_json(...)` brings it back in the next process.
!!! tip
`prior_discover=` only does anything when `mode` is a version pin. Under `"auto"` the client
probes the server anyway, and under `"legacy"` it is ignored.
## The four modes
| You write | Negotiation traffic | You get |
| --- | --- | --- |
| `Client(target)` | one `server/discover` probe; the `initialize` handshake if it fails | the newest version both sides speak, whichever era |
| `Client(target, mode="legacy")` | the `initialize` handshake | a handshake-era version; server-initiated requests work |
| `Client(target, mode="2026-07-28")` | none | that version, pinned, with a blank `server_info` |
| `Client(target, mode="2026-07-28", prior_discover=saved)` | none | that version, pinned, *and* the identity you saved last time |
## Recap
* MCP has a handshake era (up to `2025-11-25`, the `initialize` handshake) and a modern era (`2026-07-28`, `server/discover`). `Client` bridges them.
* `mode="auto"` is the default: probe, fall back. Leave it alone unless one of the other three rows describes you.
* `client.protocol_version` is always the answer to "what did I get?".
* `mode="legacy"` forces the handshake. It is what you need for server-initiated requests: sampling, push elicitation, `message_handler`.
* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`.
* `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both.
A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **Multi-round-trip requests**.

115
docs/client/transports.md Normal file
View file

@ -0,0 +1,115 @@
# Client transports
Every `Client` talks to its server over a **transport**: the thing that actually carries the messages.
You never configure one separately. `Client` takes a single positional argument and works the transport out from its type.
The *server* side of each (what `mcp.run()` does and what you deploy) is **Running your server**.
## In memory
Pass the server object itself:
```python title="client.py" hl_lines="14"
--8<-- "docs_src/client_transports/tutorial001.py"
```
No subprocess, no port, no bytes on a wire. The client and the server are two objects in the same process, and the call still goes through the real protocol layer: `search_books` is listed, validated and invoked exactly as it would be over HTTP.
That makes it two things at once:
* **A test harness.** Every example in this documentation is exercised this way, and the **Testing** chapter builds the whole pattern around it.
* **An embedding API.** An application that constructs the server doesn't need a network hop to call its tools.
## Streamable HTTP
Pass a URL string and you get **Streamable HTTP**, the transport you deploy behind:
```python title="client.py" hl_lines="5"
--8<-- "docs_src/client_transports/tutorial002.py"
```
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
!!! check
A `Client` you have constructed is **not** connected. Construction only picks the transport;
`async with` is what opens it. Reach for the connection before entering and the SDK tells you so:
```text
RuntimeError: Client must be used within an async context manager
```
Nothing was resolved, fetched or spawned when you wrote `Client("http://...")`. That line is free.
### Bring your own `httpx.AsyncClient`
The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx.AsyncClient` yourself and hand it to `streamable_http_client`:
```python title="client.py" hl_lines="8-14"
--8<-- "docs_src/client_transports/tutorial003.py"
```
Two things to notice:
* You own the `httpx.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create.
* `streamable_http_client(url, http_client=...)` returns a transport, and `Client(transport)` accepts it like anything else.
!!! warning
`streamable_http_client` used to take `headers=` and `timeout=` directly. It does not any more:
its only parameters are `url`, `http_client` and `terminate_on_close`. Reach for `headers=` out
of habit and you get:
```text
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
```
Everything HTTP-shaped now lives on the one `httpx.AsyncClient` you pass in.
!!! info
If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection
limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in:
`httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **OAuth clients**.
## 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.
Describe the process with `StdioServerParameters`, turn it into a transport with `stdio_client`, and hand *that* to `Client`:
```python title="client.py" hl_lines="4-8 12"
--8<-- "docs_src/client_transports/tutorial004.py"
```
`Client` does not accept the parameters object on its own. `StdioServerParameters` is configuration; `stdio_client(server)` is the transport that knows how to spawn a process from it. Always wrap.
Leaving the `async with` block also shuts the subprocess down: close stdin, wait, kill if it lingers. You never clean it up yourself.
!!! warning
The child does **not** inherit your environment. It gets a minimal allow-list (`HOME`, `LOGNAME`,
`PATH`, `SHELL`, `TERM` and `USER` on POSIX) so nothing sensitive leaks into a process you may
not have written.
A server that needs an API key won't find it there. Pass it explicitly with `env=`; those
variables are merged on top of the allow-list. That is what `BOOKSHOP_API_KEY` is doing above.
## SSE
`sse_client(url)`, from `mcp.client.sse`, is the HTTP transport that Streamable HTTP superseded. Wrap it the same way, `Client(sse_client("http://localhost:8000/sse"))`, to talk to a server that still speaks it, and don't build anything new on it.
## The `Transport` protocol
To `Client`, all of the above are the same thing.
A **transport** is any async context manager that yields a `(read, write)` pair of message streams: formally, the `Transport` protocol in `mcp.client`. `Client` resolves its argument by type: a server object connects in-process, a `str` becomes `streamable_http_client(url)`, and anything else is entered as a transport directly. That last rule is why `stdio_client(...)`, `streamable_http_client(...)` and `sse_client(...)` all drop into the same slot, and why you can write your own.
## Recap
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
* Headers, auth, proxies and timeouts belong on an `httpx.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
* stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone.
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol.
* Constructing a `Client` picks the transport. `async with` opens it.
Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **Protocol versions** is the page.

View file

@ -1,13 +0,0 @@
# Concepts
!!! warning "Under Construction"
This page is currently being written. Check back soon for complete documentation.
<!--
- Server vs Client
- Three primitives (tools, resources, prompts)
- Transports (stdio, SSE, streamable HTTP)
- Context and sessions
- Lifecycle and state
-->

View file

@ -3,68 +3,91 @@
!!! info "You are viewing the in-development v2 documentation"
For the current stable release, see the [v1.x documentation](https://py.sdk.modelcontextprotocol.io/).
The **Model Context Protocol (MCP)** allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction.
The **Model Context Protocol (MCP)** lets applications provide context to LLMs in a standardized way, separating the concern of *providing* context from the LLM interaction itself.
This Python SDK implements the full MCP specification, making it easy to:
This is the official Python SDK for it. With it you can:
- **Build MCP servers** that expose resources, prompts, and tools
- **Create MCP clients** that can connect to any MCP server
- **Use standard transports** like stdio, SSE, and Streamable HTTP
* **Build MCP servers** that expose tools, resources, and prompts to any MCP host.
* **Build MCP clients** that connect to any MCP server.
* Speak every standard transport: stdio, Streamable HTTP, and SSE.
If you want to read more about the specification, please visit the [MCP documentation](https://modelcontextprotocol.io).
## Requirements
## Quick Example
Python 3.10+.
Here's a simple MCP server that exposes a tool, resource, and prompt:
## Installation
=== "uv"
```bash
uv add "mcp[cli]==2.0.0a3"
```
=== "pip"
```bash
pip install "mcp[cli]==2.0.0a3"
```
The `[cli]` extra gives you the `mcp` command; you'll want it for development.
!!! warning "Pin the version while v2 is in alpha"
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 this documentation does not describe. Check
[PyPI](https://pypi.org/project/mcp/#history) for the newest alpha before you copy the line
above. See [Installation](installation.md) for the details.
## Example
### Create it
Create a file `server.py`:
```python title="server.py"
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("Test Server", json_response=True)
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers"""
return a + b
@mcp.resource("greeting://{name}")
def get_greeting(name: str) -> str:
"""Get a personalized greeting"""
return f"Hello, {name}!"
@mcp.prompt()
def greet_user(name: str, style: str = "friendly") -> str:
"""Generate a greeting prompt"""
return f"Write a {style} greeting for someone named {name}."
if __name__ == "__main__":
mcp.run(transport="streamable-http")
--8<-- "docs_src/index/tutorial001.py"
```
Run the server:
That's a complete MCP server.
```bash
uv run --with mcp server.py
It exposes one **tool**, `add`, and one templated **resource**, `greeting://{name}`.
### Run it
```console
uv run mcp dev server.py
```
Then open the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) and connect to `http://localhost:8000/mcp`:
This starts your server and opens the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), an interactive UI for poking at it. Open the URL it prints.
```bash
npx -y @modelcontextprotocol/inspector
!!! note
The Inspector is a Node.js app, so `mcp dev` needs `npx` on your `PATH`.
### Try it
In the Inspector, go to **Tools** and call `add` with `a=1`, `b=2`.
You get `3` back. ✨
The Inspector built that form (a required integer field for `a`, another for `b`) from your type hints. So will Claude, and every other MCP host.
Now go to **Resources** and read `greeting://World`:
```text
Hello, World!
```
## Getting Started
### Recap
<!-- TODO(Marcelo): automatically generate the follow references with a header on each of those files. -->
1. **[Install](installation.md)** the MCP SDK
2. **[Learn concepts](concepts.md)** - understand the three primitives and architecture
3. **[Explore authorization](authorization.md)** - add security to your servers
4. **[Use low-level APIs](low-level-server.md)** - for advanced customization
Look again at what you did **not** write:
## API Reference
* No JSON Schema. `a: int, b: int` *is* the schema.
* No request parsing, no serialization, no validation code.
* No protocol handling at all.
Full API documentation is available in the [API Reference](api/mcp/index.md).
You wrote two Python functions with type hints and a docstring. The SDK does the rest.
## Where to go next
* The **[Tutorial](tutorial/index.md)** walks through everything a server can do, one small step at a time.
* Migrating from v1? Start with the **[Migration Guide](migration.md)**.
* Hunting for an exact signature? The **[API Reference](api/mcp/index.md)** is generated from the source.

View file

@ -1,31 +1,49 @@
# Installation
The Python SDK is available on PyPI as [`mcp`](https://pypi.org/project/mcp/) so installation is as simple as:
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 alpha, so the version pin is not optional yet:
=== "uv"
```bash
uv add "mcp[cli]==2.0.0a3"
```
=== "pip"
```bash
pip install mcp
```
=== "uv"
```bash
uv add mcp
pip install "mcp[cli]==2.0.0a3"
```
The following dependencies are automatically installed:
!!! 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 alpha before you copy
the line above.
- [`httpx`](https://pypi.org/project/httpx/): HTTP client to handle HTTP Streamable and SSE transports.
- [`httpx-sse`](https://pypi.org/project/httpx-sse/): HTTP client to handle SSE transport.
- [`pydantic`](https://pypi.org/project/pydantic/): Types, JSON schema generation, data validation, and [more](https://docs.pydantic.dev/latest/).
- [`starlette`](https://pypi.org/project/starlette/): Web framework used to build the HTTP transport endpoints.
- [`python-multipart`](https://pypi.org/project/python-multipart/): Handle HTTP body parsing.
- [`sse-starlette`](https://pypi.org/project/sse-starlette/): Server-Sent Events for Starlette, used to build the SSE transport endpoint.
- [`pydantic-settings`](https://pypi.org/project/pydantic-settings/): Settings management used in MCPServer.
- [`uvicorn`](https://pypi.org/project/uvicorn/): ASGI server used to run the HTTP transport endpoints.
- [`jsonschema`](https://pypi.org/project/jsonschema/): JSON schema validation.
- [`pywin32`](https://pypi.org/project/pywin32/): Windows specific dependencies for the CLI tools.
The same applies to one-off commands: `uv run --with "mcp==2.0.0a3" ...`, not `uv run --with mcp ...`.
This package has the following optional groups:
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.
- `cli`: Installs `typer` and `python-dotenv` for the MCP CLI tools.
## 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`: modern typing features on Python 3.10.
* `pywin32`: Windows only, used for `stdio` subprocess management.
## Optional extras
* `mcp[cli]` adds [`typer`](https://typer.tiangolo.com/) and `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.

Some files were not shown because too many files have changed in this diff Show more