chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
# The Context
A tool's arguments come from the model. Everything else (the request you are serving, the server you live in, a way to talk back to the client) comes from one object: the **`Context`**.
You don't construct it and you don't configure it. You ask for it.
## Ask for it
Add a parameter annotated with `Context` to any tool:
```python title="server.py" hl_lines="2 8"
--8<-- "docs_src/context/tutorial001.py"
```
* The SDK builds a fresh `Context` for every request and passes it in.
* The parameter **name doesn't matter**. `ctx`, `context`, `c`: the SDK finds it by its annotation.
* Resources and prompts can declare one too, the same way.
* `ctx.request_id` is the id of the request your function is serving right now.
!!! info
If you've used FastAPI, you've seen this move: declare a parameter with the framework's own type
(`Request` there, `Context` here) and the framework supplies it. Nothing to register, nothing to
configure: the type annotation is the whole mechanism.
### Invisible to the model
This is the part to internalise. Here is the input schema `tools/list` reports for `search_books`:
```json
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"}
},
"required": ["query"],
"title": "search_booksArguments"
}
```
One property. `ctx` is not an argument: it never appears in the schema, the model is never told about it, and no client can fill it in. It's a contract between you and the SDK, invisible on the wire.
### Try it
Run the server with the MCP Inspector:
```console
uv run mcp dev server.py
```
The form for `search_books` has a single `query` field. Call it with `dune`:
```text
[request 3] Found 3 books matching 'dune'.
```
The number is whichever request this happened to be. Call the tool again and it changes: every request gets its own `Context`.
## What it gives you
The injected object is small. Besides `request_id`:
* `await ctx.read_resource(uri)`: read one of the server's **own** resources from inside a tool. The next section.
* `await ctx.report_progress(progress, total, message)`: stream progress back to the caller during a long call. The whole story is in **[Progress](progress.md)**.
* `await ctx.elicit(message, schema)` and `await ctx.elicit_url(...)`: pause the tool and ask the user a question. That's **[Elicitation](elicitation.md)**.
* `ctx.session`: the server's side of the conversation with this client. Notifications you send to the client live here; the last section uses it.
* `ctx.headers`: the request headers the transport carried, or `None` on stdio. Read a custom header with `(ctx.headers or {}).get("x-...")`. Headers are client-supplied input - fine for a locale or a feature flag, never an identity.
* `ctx.request_context`: the raw per-request record. The field you'll reach for is `lifespan_context`, the object your startup code yielded (see **[Lifespan](lifespan.md)**).
Logging is deliberately not on that list. A server logs with Python's `logging` module, like any other Python program. **[Logging](logging.md)** is the short page on why.
!!! tip
Injection only happens for the function you registered. A helper that your tool calls doesn't get
its own `Context`; pass `ctx` down as an ordinary argument. There is no ambient
"current context" to fetch from somewhere else.
## Read your own resources
A server's resources aren't only for clients. A tool can read them too:
```python title="server.py" hl_lines="16"
--8<-- "docs_src/context/tutorial002.py"
```
`ctx.read_resource` resolves the URI through the same registry that serves `resources/read`, so a tool gets what a client would get: an iterable of `ReadResourceContents`, one per content block. For this URI there is one:
```python
contents.content # 'fiction, non-fiction, poetry'
contents.mime_type # 'text/plain'
```
* `content` is exactly what `genres()` returned. One source of truth: the client browses the resource, your tools consume it, nobody copies the string.
* `describe_catalog`'s only parameter is the `Context`, so its input schema has **no properties at all**. The model calls it with `{}`.
## Tell the client the list changed
What a server offers is not fixed at import time. Register a tool at runtime, then tell the client:
```python title="server.py" hl_lines="15-16"
--8<-- "docs_src/context/tutorial003.py"
```
* `mcp.add_tool(recommend_book)` registers a plain function as a tool: name, description and schema derived exactly as `@mcp.tool()` would have.
* `await ctx.session.send_tool_list_changed()` sends `notifications/tools/list_changed`. A client that receives it calls `tools/list` again and sees `recommend_book`.
The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, and `send_resource_updated(uri)` for a change to one specific resource.
On a 2026-07-28 connection, clients receive change notifications only on a `subscriptions/listen` stream they opened, so the `send_*` methods above do not reach those streams. The `Context` publish methods deliver to every subscribed stream at once: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, and `await ctx.notify_resource_updated(uri)`. The whole story, including scaling out across replicas, is in **[Subscriptions](subscriptions.md)**.
!!! check
Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it
anyway and the result is an error the model can read:
```text
Unknown tool: recommend_book
```
Run `enable_recommendations`, and the very same call succeeds. The tool list is genuinely
dynamic: `tools/list` reflects whatever is registered *right now*.
## Recap
* Annotate a parameter with `Context` (in a tool, a resource, or a prompt) and the SDK injects it. The name is yours.
* It is invisible to the model: the input schema only ever contains your real arguments.
* `ctx.request_id` identifies the request; `ctx.request_context.lifespan_context` is what your startup yielded.
* `await ctx.read_resource(uri)` lets a tool read the server's own resources.
* `ctx.session` is the channel back to the client: `send_tool_list_changed()` and its siblings tell it to re-fetch a list you changed.
* Progress reporting and elicitation also start at `Context`; each has its own page.
Parameters the model never sees, filled by your own functions, are **[Dependencies](dependencies.md)**.
+158
View File
@@ -0,0 +1,158 @@
# Dependencies
A tool's arguments come from the model. Some values never should: a price looked up from your records, a confirmation only a person can give, anything the model could get wrong by inventing it.
**Dependencies** are parameters filled by your own functions. You annotate the parameter, name the function, and the SDK calls it before your tool runs.
## Declare one
Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`:
```python title="server.py" hl_lines="18-19 23"
--8<-- "docs_src/dependencies/tutorial001.py"
```
* `check_stock` is a **resolver**: a plain function the SDK runs before `reserve_book`, whose return value becomes the `stock` argument.
* Its `title` parameter is the tool's own `title` argument, matched **by name**. The resolver sees exactly the validated value the tool body will see.
* The tool body starts from a `Stock` that already exists. No lookup code in the tool, no "what if it's missing" preamble.
!!! info
If you've used FastAPI, this is `Depends`. Same move, same reason: the function declares what
it needs, the framework supplies it, and the wiring lives in the type annotation.
### Invisible to the model
Here is the input schema `tools/list` reports for `reserve_book`:
```json
{
"type": "object",
"properties": {
"title": {"title": "Title", "type": "string"}
},
"required": ["title"],
"title": "reserve_bookArguments"
}
```
One property. Like the `Context` in **[The Context](context.md)**, a resolved parameter is a contract between you and the SDK: `stock` is not in the schema, the model is never told about it, and a client that sends a `stock` value anyway is ignored. The resolver's value is the only one your tool can receive.
That last part is the point. A parameter the model cannot supply is a parameter the model cannot get wrong.
### Try it
Run the server with the MCP Inspector:
```console
uv run mcp dev server.py
```
The form for `reserve_book` has a single `title` field. `stock` is nowhere on it. Call it with `Dune`:
```text
Reserved 'Dune' (6 copies left).
```
The tool body never looked anything up: `check_stock` ran first, and the `Stock` it returned arrived as an argument. Try `Neuromancer` and the same resolver hands the tool a zero.
!!! tip
You could just call `check_stock(title)` in the tool body. Declare it as a dependency when the
value deserves more than a helper call: every tool that needs stock declares the same parameter,
and the SDK runs the resolver at most once per call, no matter how many declare it. The next
sections add the rest: resolvers that depend on each other, and resolvers that ask the user.
## Dependencies of dependencies
A resolver can declare its own dependencies, with the same annotation:
```python title="server.py" hl_lines="22 29-30"
--8<-- "docs_src/dependencies/tutorial002.py"
```
* `estimate_delivery` depends on `check_stock`. The SDK runs the graph in order: stock first, then the estimate, then the tool.
* Both `stock` and `delivery` ultimately need `check_stock`, but it runs **once per call**. One inventory lookup, two consumers.
* There is nothing to register. The graph *is* the annotations.
!!! check
Don't take once-per-call on faith. Put a `print` in `check_stock` and call `order_book` from the
Inspector: one line per call. Two consumers, one lookup.
The SDK analyses the graph when the tool is registered, not when it is called. A parameter it can't classify - not a `Context`, not a `Resolve(...)`, not a tool argument's name - and a cycle of resolvers both raise `InvalidSignature` at startup. Your server fails before a client ever connects, with the offending parameter or resolver named in the error.
A resolver's parameters resolve exactly like a tool's: another `Resolve(...)`, the tool's own arguments by name, or the `Context` - `ctx.headers`, the lifespan object, all of it.
!!! warning
On HTTP transports the `Context` includes `ctx.headers`. Headers are **client-supplied input**,
like any tool argument: fine for a locale or a feature flag, never an identity. Who the caller
is comes from your authorization layer (**[Authorization](../run/authorization.md)**), not from a header anyone can set.
!!! tip
*Once per call* means exactly that: the next `tools/call` runs `check_stock` again. A resource
that should outlive a request - a database pool, an HTTP client - belongs in **[Lifespan](lifespan.md)**, and
a resolver can reach it through `ctx.request_context.lifespan_context`.
## Ask when you must
A resolver doesn't have to know the answer. It can return `Elicit(message, Model)` and the SDK asks the user - the **[Elicitation](elicitation.md)** machinery, run for you:
```python title="server.py" hl_lines="26-32 39"
--8<-- "docs_src/dependencies/tutorial003.py"
```
* In stock: `confirm_backorder` returns a `Backorder` directly. **No question, no round-trip.** The user is only interrupted when their answer matters.
* Out of stock: the SDK sends the elicitation, validates the answer against `Backorder`, and injects it. Your resolver never touches the protocol.
* The tool reads `backorder.confirm` like any other argument. Answering **no** is still an answer: the elicitation is accepted with `confirm=False`, the tool runs, and no order is placed. Asking became a precondition, not plumbing in the tool body.
And if the user won't answer at all - declines the question, or cancels it?
!!! check
Run `order_book` for `Neuromancer` and decline the question. With the annotation written as
`Annotated[Backorder, Resolve(...)]` the tool body never runs; the call fails with an error
result the model can read:
```text
Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline
```
That's the right default for a precondition: no answer, no order. When declining is an outcome your tool wants to handle - skip the backorder but still suggest another title - annotate `ElicitationResult[Backorder]` instead and the tool receives the full accept/decline/cancel outcome to branch on. **[Elicitation](elicitation.md)** shows that form, and everything else about asking: the schema rules, the three answers, the client's side of the conversation.
!!! info
The framework picks the question's transport from the negotiated protocol version; the code
above is identical on both. On **2026-07-28** and later the question rides inside a
multi-round-trip `tools/call` - the server returns it, the client's `elicitation_callback`
answers it, and the `Client` retries the call for you (**[Multi-round-trip requests](multi-round-trip.md)**). On
**2025-11-25** and earlier it is a synchronous elicitation request mid-call. Each question is
asked exactly once per call - a guarantee about the question, not the resolver. In the
multi-round-trip form any resolver may run again whenever the call resumes after a question,
so code before a `return Elicit(...)` runs on each of those rounds; the recorded answer then
satisfies the repeated question without prompting the user again. A recorded answer is only
ever consulted when the resolver asks; a resolver that answers *without* asking, like
`check_stock`, always supplies its own computed value. Because each answer is matched back to
its question, an eliciting resolver must derive its question deterministically from the
tool's arguments and earlier answers. A per-call generated value (a `default_factory` id, a
timestamp) is re-derived on each round and must not appear in a question the answer is meant
to bind to. A question built from such volatile data makes every recorded answer look stale,
so the server re-asks it on every round until the client's round limit ends the call.
## Ask the client, not the user
Elicitation is one of the three questions a resolver can ask, and the multi-round-trip flow allows no others. The other two go to the **client** rather than the user: return `Sample(...)` to run an LLM call through the client (a `sampling/createMessage` request), or `ListRoots()` to fetch the client's current roots. Neither has an accept/decline outcome; the consumer annotates the result type directly, `CreateMessageResult` (`CreateMessageResultWithTools` when the request carries `tools` or `tool_choice`) or `ListRootsResult`:
```python title="server.py" hl_lines="11-16 22"
--8<-- "docs_src/dependencies/tutorial004.py"
```
* The framework routes these exactly like `Elicit`: inside the multi-round-trip `tools/call` on **2026-07-28**, over the standalone server->client request on **2025-11-25**. An undeclared capability refuses the call with a `-32021` protocol error (`sampling`, `roots`, form-mode `elicitation`; `sampling.tools` when the request carries `tools` or `tool_choice`).
* Everything the info box above says about questions applies unchanged: a `Sample` request is matched to its recorded result by its exact rendering, so build it deterministically from the tool's arguments and earlier answers; the client then pays for the LLM call once per tool call, not once per round. The recorded result rides `request_state` for the rest of the call, so a very large completion makes every remaining round-trip heavier.
* The standalone sampling and roots *features* are deprecated at 2026-07-28 (SEP-2577). New servers that need the client's model ask through this carrier; servers that don't should integrate with an LLM provider directly. `include_context` values other than `"none"` are themselves deprecated; avoid them.
## Recap
* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value.
* A resolved parameter is invisible to the model and cannot be supplied by a client. Values the model must not invent - prices, identities, permissions - belong here.
* A resolver's parameters are resolved the same way: the `Context`, another `Resolve(...)`, or a tool argument by name. The graph runs each resolver at most once per round, however many consumers it has; each question is asked exactly once, and any resolver may run again when a call resumes after a question.
* Bad graphs fail at registration with `InvalidSignature`, not mid-call.
* Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch.
* Return `Sample(...)` or `ListRoots()` to ask the client for an LLM completion or the roots list; the plain result is injected.
The state your server builds once at startup, and how a handler reaches it, is the **[Lifespan](lifespan.md)** page.
+185
View File
@@ -0,0 +1,185 @@
# Elicitation
A tool that is halfway through its job and missing one answer doesn't have to fail.
**Elicitation** lets it ask. In the middle of a tool call the user gets a question, and their answer comes back into the same function call.
There are two modes:
* **Form mode**: you need a value (a confirmation, a date, a quantity). You describe the fields, the client renders the form.
* **URL mode**: you need the user to go somewhere else (an OAuth consent screen, a payment page). Nothing they do there passes through the protocol.
And there are two ways to ask. The one to reach for is a **resolver**: you hang the question on a parameter, and the SDK asks - on any connection, whatever protocol era the client speaks. The direct way, `await ctx.elicit(...)`, is a request from the *server* to the *client*, a channel that only exists for a client on a legacy connection (spec version 2025-11-25 or earlier). Both are on this page; start with the resolver.
## Ask with a resolver
A question that gates the whole tool - *are you sure? which of the three matching accounts?* - can be lifted out of the tool body into a **resolver**, and the framework asks it for you.
A parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body. The resolver returns the value directly when it already knows it, or returns `Elicit(...)` to have the framework ask:
```python title="server.py" hl_lines="24-30 35-36"
--8<-- "docs_src/elicitation/tutorial004.py"
```
* `confirm_delete` reads the tool's own `path` argument by name, lists the folder, and **only elicits when it must** - an empty folder resolves to `Confirm(ok=True)` with no round-trip to the client.
* `delete_folder` annotates `ElicitationResult[Confirm]`, so the framework injects the whole outcome and the tool `match`es every case: accept-and-confirm, accept-but-keep (`ok=False`), decline, cancel.
* The `confirm` parameter never appears in the tool's input schema - the client supplies `path`, the resolver supplies `confirm`.
Annotate the unwrapped model (`Annotated[Confirm, Resolve(confirm_delete)]`) instead when the tool doesn't need to branch: it receives the model on accept and the call aborts with an error on decline or cancel.
A resolver works on **every** connection. For a client on a legacy connection the SDK sends it the question directly; on a **2026-07-28** connection the SDK *returns* the question from the call, and the client's next attempt carries the answer. Your resolver never knows the difference; what happens underneath is **[Multi-round-trip requests](multi-round-trip.md)**.
Asking is only one thing a resolver can do. The general mechanism - dependencies that compute without asking, dependencies of dependencies, what the model can and cannot supply - is the **[Dependencies](dependencies.md)** page.
## Ask from inside the tool
A tool can also stop in the middle of its own body and ask.
!!! warning
`ctx.elicit()` and `ctx.elicit_url()` are requests from the *server* to the *client* - a
channel that only exists for a client on a legacy connection (spec version **2025-11-25**
or earlier). On a **2026-07-28** connection there are no server-initiated requests, so
these calls fail. A resolver works on both. **[Protocol versions](../protocol-versions.md)**
has the whole story.
`await ctx.elicit()` takes a message and a Pydantic model:
```python title="server.py" hl_lines="9-11 20-23 25"
--8<-- "docs_src/elicitation/tutorial001.py"
```
* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own page: **[The Context](context.md)**.
* `AlternativeDate` is the **schema** of the answer you want.
* The tool is `async def`. It has to be: it stops in the middle and waits for a person.
* On any other date the tool returns straight away. It only asks when it has to.
* The date the user accepts goes back through `book_table` itself. An answer is input like any other: an alternative that is also fully booked gets asked about again, not confirmed blind.
### What the client receives
The client gets your message and, next to it, a JSON Schema generated from the model:
```json
{
"properties": {
"accept_alternative": {
"description": "Try another date?",
"title": "Accept Alternative",
"type": "boolean"
},
"date": {
"default": "2025-12-26",
"description": "Alternative date (YYYY-MM-DD)",
"title": "Date",
"type": "string"
}
},
"required": ["accept_alternative"],
"title": "AlternativeDate",
"type": "object"
}
```
That schema is the form. `Field(description=...)` is the label; a default pre-fills the input and makes the field optional. It's the same Pydantic-to-JSON-Schema machinery **[Tools](../servers/tools.md)** describes for a tool's arguments.
!!! warning
An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields
only: `str`, `int`, `float`, `bool`, or a `Literal` of strings (it becomes an `enum`).
Put a model inside the model and `ctx.elicit` raises before anything is sent to the client:
```text
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
```
You are interrupting a person mid-task. If the answer needs nesting, it should have been an
argument to the tool.
### The three answers
`result.action` tells you what the user did, and there are exactly three possibilities:
* `"accept"`: they submitted the form. `result.data` is an `AlternativeDate` instance, already validated.
* `"decline"`: they said no.
* `"cancel"`: they dismissed the question without choosing.
`result.data` only exists on `"accept"`, which is why the example checks `result.action` first. Your type checker enforces the order: after `result.action == "accept"`, `result.data` is an `AlternativeDate`; before it, there is no `.data` at all.
A refusal is not an error. The tool decides what declining means (here, no booking) and answers the model normally.
!!! tip
The answer is validated against your model before your code sees it. A client that sends
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a
schema-mismatch error, your `if` never runs.
## Send the user to a URL
Some things must not go through the model or the client: credentials, card numbers, OAuth consent. For those you don't ask for data; you ask the user to go somewhere:
```python title="server.py" hl_lines="10-14 23"
--8<-- "docs_src/elicitation/tutorial002.py"
```
* `ctx.elicit_url()` takes the message, the **URL** to visit, and an `elicitation_id` you choose: any string that identifies this elicitation within your server.
* The result has an action and nothing else. `"accept"` means the user agreed to open the URL, **not** that they finished what's on the other side.
* The payment happens out of band, between the user's browser and your payment provider. No content ever comes back through MCP.
Look at the second tool. When your server learns the out-of-band flow finished (a webhook, a poll; here it's modelled as a second tool), `ctx.session.send_elicit_complete(...)` sends `notifications/elicitation/complete` with the same `elicitation_id`. That is how the client knows it can stop showing *"waiting for payment..."*. Without it, the client can only guess.
## The client side
Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(...)`:
```python title="client.py" hl_lines="7-8 19"
--8<-- "docs_src/elicitation/tutorial003.py"
```
* One callback handles both modes. `params` is a union of `ElicitRequestFormParams` and `ElicitRequestURLParams`; `isinstance` is the branch.
* For a URL, you show `params.url` to the user and return the action they chose. Never any `content`.
* For a form, a real application renders `params.requested_schema` and returns the user's input as `content`. This one always says yes with a canned answer, which is exactly the callback you want in a test.
* Passing the callback is also the **capability declaration**: it's how the server learns this client can be asked. The other things a client can answer for a server live in **[Client callbacks](../client/callbacks.md)**.
!!! info
Elicitation is a request from the *server* to the *client*, and those only exist on a
classic-handshake session, which is why this client passes `mode="legacy"`.
On a **2026-07-28** connection a tool asks by *returning* the question from the call
instead; that flow is **[Multi-round-trip requests](multi-round-trip.md)**.
### Try it
Start the `ctx.elicit` form-mode `server.py` (the `book_table` one) on Streamable HTTP (**[Running your server](../run/index.md)** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day.
The callback prints the question it was sent:
```text
No tables for 2 on 2025-12-25. Would you like to try another date?
```
It answers with `{"accept_alternative": True, "date": "2025-12-27"}`, and the tool, which has been waiting inside `await ctx.elicit(...)` this whole time, finishes the booking:
```text
Booked a table for 2 on 2025-12-27.
```
Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit`: the same callback takes the other branch, prints the payment link, and the tool comes back with *"Complete the payment in your browser."* One round trip, mid-call, in both directions.
!!! check
Now remove `elicitation_callback=` from the `Client` and call `book_table` for Christmas day
again. The whole call fails with a protocol error:
```text
Elicitation not supported
```
A client that registered no callback never declared the `elicitation` capability, so there is
nobody to ask. Your tool didn't get a `"decline"`; it got an exception. Design for it: every
elicitation needs a sensible answer to "what if I can't ask?".
## Recap
* A parameter annotated `Annotated[T, Resolve(fn)]` is filled by a resolver, which returns `Elicit(...)` when it has to ask. It works on every connection.
* The schema is a flat Pydantic model: primitive fields only, validated on the way back.
* `result.action` is `"accept"`, `"decline"` or `"cancel"`; `result.data` exists only on accept.
* `await ctx.elicit(message, schema=Model)` asks from inside the tool body, and `await ctx.elicit_url(message, url, elicitation_id)` is for everything that must not pass through the model (`ctx.session.send_elicit_complete(elicitation_id)` says the out-of-band part is done). Both are server-to-client requests: they need the client on a legacy connection.
* The client answers with one `elicitation_callback`, branching on the params type; registering it is what declares the capability.
* On a 2026-07-28 connection the server returns the question instead of pushing it; the same callback is fed by **[Multi-round-trip requests](multi-round-trip.md)**.
Everything underneath that return (the retry loop, protecting `requestState`, driving it yourself) is **[Multi-round-trip requests](multi-round-trip.md)**.
+31
View File
@@ -0,0 +1,31 @@
# Inside your handler
A handler's arguments come from the client. Everything *else* it can read, and
everything it can do while it runs, is here.
What it can read:
* **[The Context](context.md)** is the one extra parameter any handler can
ask for: the live request, its headers, its session, and the progress and
change-notification verbs.
* **[Dependencies](dependencies.md)** are parameters the model never sees,
filled in by your own functions with `Resolve`.
* **[Lifespan](lifespan.md)** covers state your server builds once at
startup, and how a handler reaches it through the `Context`.
What it can do while it runs:
* Ask the user for more input with **[Elicitation](elicitation.md)**, and
**[Multi-round-trip requests](multi-round-trip.md)**, the 2026-07-28
pattern that carries it.
* Ask the client for an LLM completion or its workspace folders with
**[Sampling and roots](sampling-and-roots.md)**, deprecated but still
served.
* Report **[Progress](progress.md)** on something slow.
* Write logs (to standard error, for whoever operates the server) with
**[Logging](logging.md)**.
* Tell subscribed clients that something changed with
**[Subscriptions](subscriptions.md)**.
If you haven't registered a handler yet, start with
**[Tools](../servers/tools.md)**. Every page here assumes you have one.
+102
View File
@@ -0,0 +1,102 @@
# Lifespan
Most real servers hold something for their whole life: a database pool, an HTTP client, a loaded model.
You don't want to build it on every call, and you do want to close it cleanly. That's what the **lifespan** is for.
## A typed lifespan
A lifespan is an `@asynccontextmanager` that receives the server and `yield`s **one object**. Whatever you yield is available to every handler for as long as the server runs.
```python title="server.py" hl_lines="25-31 34 38 40"
--8<-- "docs_src/lifespan/tutorial001.py"
```
Read it bottom-up:
* `app_lifespan` connects the `Database` **before** the `yield` and disconnects it **after**, in a `finally`. That's startup and shutdown.
* It yields an `AppContext`, a plain dataclass holding the things you set up. One field today, ten tomorrow.
* `MCPServer("Bookshop", lifespan=app_lifespan)` is the whole wiring.
* Inside the tool, the yielded object is `ctx.request_context.lifespan_context`.
The lifespan runs **once**. It is entered when the server starts (before the first request) and exited when the server stops. Every request in between shares the same `AppContext`.
!!! info
If you've written a FastAPI `lifespan`, you already know this. Same decorator, same `yield`, same `finally`.
### What the model sees
Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it never reaches the input schema:
```json
{
"type": "object",
"properties": {
"genre": {"title": "Genre", "type": "string"}
},
"required": ["genre"],
"title": "count_booksArguments"
}
```
`genre` is the only argument the model can pass. The lifespan is your server's business.
`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too, written as a bare `Context` for a reason the next section gets to. Everything `ctx` carries is in **[The Context](context.md)**.
### It really is typed
Look at the annotation again: `ctx: Context[AppContext]`.
That one type parameter is why `ctx.request_context.lifespan_context` **is** an `AppContext` to your type checker. `.db` autocompletes; `.dbb` is an error before you ever run the server.
Write a bare `Context` instead and `lifespan_context` is typed as `dict[str, Any]`: the type checker has no way to know what your lifespan yielded. The object is still there at runtime; you've lost the help.
!!! warning
`Context[AppContext]` is a **tool-only** spelling. Put it on an `@mcp.resource()` or
`@mcp.prompt()` function and every call to that handler fails. The client gets an error back,
and the server log shows why:
```text
Context is not available outside of a request
```
In resources and prompts, write the bare `ctx: Context`. The object your lifespan yielded is
still `ctx.request_context.lifespan_context` at runtime; you give up the type parameter, not
the object.
!!! tip
There is always a lifespan. If you don't pass one, the SDK's default yields an empty `dict`,
so `ctx.request_context.lifespan_context` is `{}`, never `None`. That default is also why a
bare `Context` types it as `dict[str, Any]`.
## Watch it happen
"Startup runs before the first request" is the kind of sentence you should not have to take on faith.
Strip the server down to the lifecycle: give `Database` a `connected` flag, flip it in `connect()` and `disconnect()`, and add a tool that reports it.
```python title="server.py" hl_lines="11 14 17 25 44"
--8<-- "docs_src/lifespan/tutorial002.py"
```
`database` lives at module level for one reason: so you can look at it from *outside* the server.
!!! check
Three moments, three values:
* Before the server starts, `database.connected` is `False`. Importing the module connected nothing.
* While it's running, call `database_status` and the result is `"connected"`.
* Stop the server and the `finally` block runs: `database.connected` is `False` again.
The work happened exactly where you put it: around the `yield`, not at import time and not per request.
## Recap
* `lifespan=` takes an `@asynccontextmanager` that receives the server and `yield`s one object.
* Code before the `yield` is startup. The `finally` after it is shutdown.
* It runs once, around the whole life of the server, not per request.
* Whatever you `yield` is `ctx.request_context.lifespan_context` in every tool, resource, and prompt.
* `ctx: Context[AppContext]` makes that access fully typed in tools. Resources and prompts take the bare `Context`.
* No `lifespan=` means an empty `dict`, never `None`.
A handler that stops mid-call to ask the user for something only they know is **[Elicitation](elicitation.md)**.
+78
View File
@@ -0,0 +1,78 @@
# Logging
Log from a tool the way you log from any other Python function: with the standard library.
MCP has a protocol-level **logging capability**: a server could push its log messages to the client as notifications, through methods on the `Context` object. The 2026-07-28 revision of the spec **deprecates that capability and does not replace it**, so these docs don't teach it. The full list of what's deprecated and what to do instead is in **[Deprecated features](../deprecated.md)**.
What you do instead is what you do in every other Python program: the standard library.
## A tool that logs
```python title="server.py" hl_lines="1 5 13"
--8<-- "docs_src/logging/tutorial001.py"
```
* `logging.getLogger(__name__)` gives you a logger named after your module. Create it once, at the top.
* Inside the tool you call `logger.info(...)` like in any other function. Nothing to inject, nothing to `await`, nothing MCP-specific.
!!! check
Call the tool and look at the whole result:
```python
result.content # [TextContent(text="Found 3 books matching 'dune'.")]
result.structured_content # {'result': "Found 3 books matching 'dune'."}
```
The log line is nowhere in it. Logging is for **you**, the person operating the server. The model
never sees it. If the model should read something, `return` it.
## Where it goes
For a **stdio** server this question matters more than usual. The host launched your server as a subprocess and is reading MCP messages from its **stdout**. Standard error is yours.
The standard library already does the right thing: log output goes to `sys.stderr` by default. Your `logger.info(...)` lines land in the terminal (or wherever the host collects the subprocess's stderr), and the protocol stream stays clean.
!!! tip
Never `print()` in a stdio server. `print` writes to **stdout**, and stdout *is* the wire: one stray
line and the client is trying to parse it as JSON-RPC.
`logger.debug("got here")` is the same one line of effort and goes to the right place.
## The level
You don't have to call `logging.basicConfig()` yourself. Constructing an `MCPServer` already did, with a handler pointed at standard error, at the level you pass as `log_level=`, so `MCPServer("Bookshop", log_level="DEBUG")` is all it takes to see your `logger.debug(...)` lines.
The default is `"INFO"`.
`logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins.
## Try it
Run the server with the MCP Inspector:
```console
uv run mcp dev server.py
```
Call `search_books` from the **Tools** tab. The Inspector shows you the result: only the return value. The line
```text
Searching for 'dune'
```
went to standard error: the terminal, not the wire.
!!! info
If what you actually want is *tracing* (every request, how long it took, whether it failed), you
don't want log lines, you want spans. Your server already emits them: the SDK traces every
message with OpenTelemetry out of the box. See **[OpenTelemetry](../run/opentelemetry.md)**.
## Recap
* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it.
* `logger = logging.getLogger(__name__)` at module level, `logger.info(...)` in the tool. That's the whole pattern.
* Log output never reaches the model. Only the value you `return` does.
* Standard error is yours; stdout belongs to the protocol. Never `print()` in a stdio server.
* `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone.
Telling connected clients that something on your server changed (the tool list, a resource) is **[Subscriptions](subscriptions.md)**.
+186
View File
@@ -0,0 +1,186 @@
# 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
On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user (`Elicit`), samples the client's LLM (`Sample`), or lists its roots (`ListRoots`) and the SDK returns the `InputRequiredResult` for you; that form is the **[Dependencies](dependencies.md)** page. The two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body. A declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. The manual form is 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](../advanced/low-level-server.md)**. This page only adds the second return type.
## Beyond tools
`tools/call` is not special: at 2026-07-28 a server may answer `prompts/get` and `resources/read` the same way. On `MCPServer`, an `@mcp.prompt()` function — or an `@mcp.resource()` **template** function — returns the `InputRequiredResult` itself and reads the retry's answers off the context:
```python title="server.py" hl_lines="21 23 25"
--8<-- "docs_src/mrtr/tutorial004.py"
```
* The first round returns the `InputRequiredResult`. On the retry, `ctx.input_responses` holds the answers under the same keys and the function returns its ordinary result — prompt messages here, resource content for a template resource.
* A `request_state` you set is sealed before it crosses the wire and verified on the echo, like everything else on the server; **[Protecting `requestState`](#protecting-requeststate)** below covers what the seal gives you and when you need to configure keys.
* An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit.
* Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask.
* The era rules below apply unchanged: returning an `InputRequiredResult` on a pre-2026 session is the same `-32603` the warning describes.
## The client side
`Client` runs the loop for you.
Register the callbacks the server might ask for (`elicitation_callback`, `sampling_callback`, `list_roots_callback`) and call the tool. When an `InputRequiredResult` arrives, `Client` dispatches each entry in `input_requests` to the matching callback, retries with the answers and the echoed `request_state`, and keeps going until a `CallToolResult` comes back:
```python title="client.py" hl_lines="12 13"
--8<-- "docs_src/mrtr/tutorial003.py"
```
* That `elicitation_callback` is the same one a pre-2026 server's back-channel `elicitation/create` would have hit. The same is true of `sampling_callback` for `sampling/createMessage` and `list_roots_callback` for `roots/list`: at 2026-07-28 the standalone server->client RPCs are gone, but the identical `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` payloads ride inside `input_requests` and dispatch to the same three callbacks. One set of callbacks serves both eras.
* `call_tool` returns a plain `CallToolResult`. The intermediate rounds are invisible to the caller.
* `get_prompt` and `read_resource` drive the same loop.
!!! check
Leave the callback off and the loop fails on the first round: the SDK's stand-in callback
answers every elicitation with an error, and `call_tool` raises `MCPError` with the message
*"Elicitation not supported"*.
The loop is bounded. `Client(..., input_required_max_rounds=10)` is the default cap; a server that keeps returning `InputRequiredResult` past it makes `call_tool` raise. If a round carries only `request_state` and no `input_requests`, `Client` sleeps briefly (50ms doubling to a 250ms ceiling) before retrying, so a server that is just saying *"not done yet"* isn't busy-polled.
### Driving the loop yourself
The auto-loop is enough for a single-process client. Own the loop instead when:
* Your client is **distributed**: the process that renders the question to the user is not the process that called `call_tool`, so a different worker issues the retry. `request_state` is the persistable token you carry across that boundary, through your own storage, and `input_responses` is what the other side sends back with it.
* You want to **inspect** each round: log or audit every `input_requests` entry, refuse certain request kinds, or apply your own backoff between legs.
* You want a **wall-clock** bound rather than a round-count bound: wrap your own loop in `anyio.fail_after(...)` instead of relying on `input_required_max_rounds`.
Drop to the underlying session, where `allow_input_required=True` hands you the union directly:
```python title="client.py" hl_lines="13 14 20"
--8<-- "docs_src/mrtr/tutorial002.py"
```
* `client.session.call_tool(..., allow_input_required=True)` widens the return type to `CallToolResult | InputRequiredResult`. The `isinstance` is what narrows it back.
* `request_state` is now in your hands. Write it down between legs and the conversation can resume from a fresh process.
* 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.
## Protecting `requestState`
Everything above treats `request_state` as an echo, and on the wire that is all it is. But the client holds it between legs (writing it down across processes is exactly what the previous section blessed), so what comes back is **client-supplied input**: it can be modified, expired, or lifted from a different call entirely. The spec requires servers to integrity-protect this state and reject the round when verification fails, whenever the state can influence authorization, resource access, or business logic.
`MCPServer` protects it by default. Every server seals outgoing `requestState` and verifies every echo — resolver state and hand-built state alike — under a key generated at process start. You configure nothing, write plaintext, and read plaintext; the wire only ever carries an opaque encrypted token.
The default key lives and dies with the process, which is the one thing you must know before deploying beyond a single process:
```python
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each).
mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key]))
```
* **The default (no configuration)** suits a single process: stdio, or exactly one HTTP worker. A retry that lands on a different worker, a different instance behind a load balancer, or the same server after a restart is sealed under a key that process doesn't have — the client gets the frozen rejection below and must start the flow over.
* **`keys=[...]`** is required whenever a retry can reach a **different instance** (multi-worker `uvicorn`, load-balanced HTTP) or must survive restarts: every instance verifies what any sibling minted. Same machinery, your secret instead of a generated one.
* For your own crypto, such as a KMS or an existing token service, pass `RequestStateSecurity(codec=...)` instead of `keys`; **[Bring your own crypto](#bring-your-own-crypto)** below covers the contract.
### What the seal carries
Default or configured, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to:
* **A time window.** Every round re-seals with a fresh expiry, so `RequestStateSecurity(ttl=...)` (default 600 seconds) bounds per-round think time, not the whole flow.
* **The authenticated principal.** When the request carries an OAuth access token the SDK validated, the state is bound to the token's client, issuer, and subject: state minted for one user fails under another, even when both users share one OAuth client. A verifier that supplies no subject degrades the binding to the client identity alone, which under URL-based client IDs is shared by every user of that client software. When auth is terminated outside the SDK (a fronting proxy), or the transport is unauthenticated, there is no principal to bind and this check is inert, unless `RequestStateSecurity(bind_principal=...)` supplies one from your own identity signal. Whichever components your token verifier supplies, it must supply them consistently: a verifier that includes the subject on some requests and omits it on others changes the principal mid-flow, and in-flight rounds are rejected.
* **The originating request.** The method, the tool or prompt name (or resource URI), and a digest of the arguments. A token replayed against a different tool, different arguments, or a different method fails.
* **The exact question asked.** Every resolver answer is pinned to the rendered question the client was shown, both on the round it first arrives and when a recorded answer is reused later. Redeploy with a reworded message or a changed schema and the server re-asks instead of consuming a stale answer. The same pinning cuts the other way: derive messages from the tool's arguments, not from per-call data. A message built from a timestamp or a live rate renders differently every round, so every recorded answer looks stale and the server re-asks until the client's round limit ends the call.
All of that is the SDK's job, not yours, and not the codec's if you bring your own.
### Rotating keys
`keys[0]` seals new state; every key in the list verifies. Zero-downtime rotation is three phases, each fully rolled out before the next:
```python
RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints
RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying
RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD
```
Never promote the minter first: minting under a key some instance can't yet verify drops in-flight rounds mid-rollout.
Keys are scoped to one service. The sealed envelope also carries the server's name as an audience claim, so a token minted by a different service that happens to share a secret is rejected anyway. The claim is only as distinctive as the name, so a server given an explicit policy must have a real name or set `RequestStateSecurity(audience=...)` — an unnamed one raises at construction. `audience=` also serves deliberate multi-service topologies where one service must accept state another minted. (The no-configuration default is exempt: its key never leaves the process, so the audience claim has nothing to add.)
### Bring your own crypto
`RequestStateSecurity(codec=...)` takes anything with `seal(bytes) -> str` and `unseal(str) -> bytes` that raises `InvalidRequestState` for any token it did not mint. The classic shape is envelope encryption against a KMS, where you unwrap a data key once at startup and keep the per-token crypto local:
```python title="server.py" hl_lines="12 26-27 34-35 38"
--8<-- "docs_src/mrtr/tutorial005.py"
```
TTL, principal binding, and request binding are **not** the codec's job: the SDK stamps them into the payload before `seal` and re-verifies them after `unseal`, for every codec. A codec's only obligations are integrity (tampered means raise) and, ideally, confidentiality.
### When verification fails
Every inbound failure, whether tampered, expired, replayed against a different request or principal, or sealed under a key this server doesn't know, gets the same answer:
```json
{"code": -32602, "message": "Invalid or expired requestState"}
```
One frozen message for every cause, so the wire never reveals which check failed; the real reason goes to the server log. Every inbound `requestState` on `tools/call`, `prompts/get`, and `resources/read` is checked, including one arriving for a handler that never mints state. The most common rejection in practice isn't an attacker — it's the default process-local key meeting a retry from before a restart or from another instance; the client restarts the flow, and `keys=[...]` is the fix when that matters.
### Hand-built state
A `request_state` you set yourself (returning `InputRequiredResult` from a tool, prompt, or resource-template function) is sealed and verified by the same machinery as resolver state, with zero code changes: write plaintext, read plaintext, and every binding above applies.
The one thing the SDK cannot pin for you, even when configured, is question identity: it doesn't know which of *your* questions an answer in your state belongs to. If you store answers keyed by question, include your own question identifier in the state and check it on the retry.
The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is sealed until you append the boundary yourself, and your `request_state` crosses the wire exactly as written until you do. The one-line opt-in is shown in **[The low-level Server](../advanced/low-level-server.md#the-other-handlers)**.
## 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](elicitation.md)**.
## 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.
* `Client` runs the retry loop for you: register `elicitation_callback` / `sampling_callback` / `list_roots_callback` and `call_tool` returns a plain `CallToolResult`. `input_required_max_rounds` (default 10) bounds it.
* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself.
* On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](dependencies.md)**); the **low-level** `Server` is the manual form.
* Prompts and resources participate too: an `@mcp.prompt()` or template `@mcp.resource()` function returns the `InputRequiredResult` itself and reads `ctx.input_responses` on the retry.
* `requestState` comes back as client-supplied input, so `MCPServer` seals it by default — resolver state and hand-built state alike — under a process-local key; multi-instance deployments pass `RequestStateSecurity(keys=[...])` (or a custom codec) so every instance can verify what a sibling minted. The seal binds every token to a time window, the originating request, and the authenticated principal when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**).
This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](../deprecated.md)**.
+117
View File
@@ -0,0 +1,117 @@
# Progress
A tool that takes thirty seconds and says nothing for thirty seconds looks broken.
**Progress notifications** fix that. The tool reports how far along it is; the client decides what to draw with it: a bar, a spinner, a log line.
## Report it from the tool
Take a **`Context`** parameter and call `report_progress`:
```python title="server.py" hl_lines="8 11"
--8<-- "docs_src/progress/tutorial001.py"
```
Three arguments, and you decide what they mean:
* `progress`: how far you are. The spec requires it to **increase** with every report; never repeat a value or go backwards.
* `total`: how much there is in total, if you know. Optional.
* `message`: one human-readable line about *this* step. Optional.
`ctx` is injected because of its type hint and the model never sees it: `import_catalog`'s input schema has a single property, `urls`. **[The Context](context.md)** page is all about that object; progress is one of the things it gives you.
## Listen for it from the client
The client opts in **per call**, by passing `progress_callback=` to `call_tool`:
```python title="client.py" hl_lines="7 16"
import anyio
from mcp import Client
from server import mcp
async def show(progress: float, total: float | None, message: str | None) -> None:
print(f"{message} ({progress}/{total})")
async def main() -> None:
async with Client(mcp) as client:
result = await client.call_tool(
"import_catalog",
{"urls": ["https://example.com/a.json", "https://example.com/b.json"]},
progress_callback=show,
)
print(result.structured_content)
anyio.run(main)
```
The callback is an `async` function taking exactly what the server reported: `progress`, `total`, `message`.
!!! info
`Client(mcp)` connects straight to the server object, in memory, the same client the **[Testing](../get-started/testing.md)**
page is built on. `progress_callback` is the same parameter whatever transport the `Client`
uses; the *timing* you are about to see is the in-memory connection's. It runs your callback
inline, so every report lands before `call_tool` returns. Over a real transport the
notifications race the result, and a slow callback can still be running after `call_tool` has
returned.
### Try it
Put `client.py` next to `server.py` and run it:
```console
python client.py
```
```text
Imported https://example.com/a.json (1/2)
Imported https://example.com/b.json (2/2)
{'result': 'Imported 2 records.'}
```
Every `await ctx.report_progress(...)` on the server became one call to `show` on the client, in order, and both lines printed **before** `call_tool` returned. Progress is not bundled into the result; it streams while the tool is still working.
!!! warning
`progress_callback` belongs to the **call**, not the `Client`. There is no constructor argument
for it, because different calls want different callbacks: one drives a download bar, the next
one a log line.
!!! check
Now delete `progress_callback=show` and run it again:
```text
{'result': 'Imported 2 records.'}
```
No error, no warning, same result. `report_progress` is a **no-op when the caller didn't ask
for progress**, so you report unconditionally and never have to wonder whether anyone is
listening.
## When you don't know the total
`total` is for when you know the denominator. Often you don't: you're draining a feed, walking a cursor, downloading something with no length header.
Leave it out:
```python title="server.py" hl_lines="20"
--8<-- "docs_src/progress/tutorial002.py"
```
The callback receives `total=None`. A client can still show *activity* ("3 imported so far...") but it can't show a percentage. Don't invent a total to get a prettier bar.
!!! tip
`progress` doesn't have to count anything in particular. Bytes, rows, pages: pick the unit the
user would recognise, and only promise a `total` you can keep.
## Recap
* `await ctx.report_progress(progress, total=None, message=None)` from any tool that takes a `Context`.
* The client passes `progress_callback=` to `call_tool`: per call, never on the `Client`.
* The callback is `async (progress, total, message) -> None` and fires while the tool is still running.
* No callback on the call means `report_progress` does nothing. Report unconditionally.
* Omit `total` when you don't know it; the callback gets `None`.
Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **[Logging](logging.md)**.
+46
View File
@@ -0,0 +1,46 @@
# Sampling and roots
A handler can ask the connected client for two more things: a completion from the client's own model (**sampling**), and the client's workspace folders (**roots**).
Both still work, on every protocol version the SDK speaks. But read the warning before you design around them:
!!! warning "Deprecated by the 2026-07-28 specification"
Sampling and roots are deprecated as of `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). They remain fully functional and stay in the specification for at least twelve months before becoming eligible for removal, but new implementations should not build on them. The suggested migrations: integrate directly with your LLM provider's API instead of sampling, and pass directories via tool parameters, resource URIs, or server configuration instead of roots. The SDK-wide list is in **[Deprecated features](../deprecated.md)**.
## Sampling: borrow the client's model
A resolver returns `Sample(...)` and the tool receives the completion, through the same dependency mechanism that runs `Elicit` in **[Dependencies](dependencies.md)**:
```python title="server.py" hl_lines="11-16 20"
--8<-- "docs_src/sampling_and_roots/tutorial001.py"
```
* `Sample(messages, max_tokens=...)` mirrors the `sampling/createMessage` parameters. The injected value is the client's `CreateMessageResult`; pass `tools` or `tool_choice` and it becomes a `CreateMessageResultWithTools` instead.
* The client must have declared the `sampling` capability (`sampling.tools` if you pass `tools` or `tool_choice`). If it didn't, the call fails with a `-32021` protocol error instead of sending a request the client cannot handle. A pre-2026 session with no back-channel fails with its usual no-back-channel error, since there is nothing to send on.
* At `2026-07-28` the request is delivered inside the multi-round-trip flow (**[Multi-round-trip requests](multi-round-trip.md)**); on `2025-11-25` it is a standalone request to the client. The code is the same either way, but mind the multi-round-trip rule: the request must render identically across retry rounds, so build it only from the tool's arguments and other stable data.
* Leave `include_context` alone: values other than `"none"` are themselves deprecated (SEP-2596) and need a capability almost no client declares.
## Roots: where should this go?
Roots are the folders the client says the server may operate on. They are informational guidance, not an access-control mechanism. A resolver returns `ListRoots()`:
```python title="server.py" hl_lines="11-12 16"
--8<-- "docs_src/sampling_and_roots/tutorial002.py"
```
* The injected `ListRootsResult` carries a list of `Root`s: a `file://` URI and an optional display name.
* The gate is the same as for sampling: without a declared `roots` capability the call fails with `-32021` instead of sending the request.
On the other side of the wire, the client answers both requests with the callbacks it already has: `sampling_callback` and `list_roots_callback`, covered in **[Client callbacks](../client/callbacks.md)**.
## On 2025-era connections
`ctx.session.create_message(...)` and `ctx.session.list_roots()` still exist for code that drives the session directly. They only work where a back-channel exists (2025-era, non-stateless connections), and calling them raises a deprecation warning. The resolver markers above are the supported form: they pick the delivery from the negotiated version and don't warn.
## Recap
* Return `Sample(...)` or `ListRoots()` from a resolver; the tool receives the `CreateMessageResult` or `ListRootsResult` like any other dependency.
* The client must declare the matching capability, or the call fails with `-32021` instead of a request being sent.
* Both features are deprecated at `2026-07-28`: fully functional for now, wrong for new designs. Prefer provider APIs over sampling and explicit parameters over roots.
Reporting how far along a slow tool is: **[Progress](progress.md)**.
+146
View File
@@ -0,0 +1,146 @@
# Subscriptions
A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes.
**Subscriptions** are how a client hears about it. The client sends one `subscriptions/listen` request, and the response to that request *is* the stream: it stays open and carries the change notifications the client asked for.
## Publish it from the tool
Your side of it is one line: publish the change.
```python title="server.py" hl_lines="20 32"
--8<-- "docs_src/subscriptions/tutorial001.py"
```
* `await ctx.notify_resource_updated("board://sprint")` reaches every open stream that subscribed to that URI. Nobody else.
* `await ctx.notify_tools_changed()` reaches every stream that asked for tool-list changes. A client that receives it calls `tools/list` again, and now sees `sprint_report`.
* The siblings are `notify_prompts_changed()` and `notify_resources_changed()`.
* No subscribers, no work. Publishing to an idle server is a no-op, so you never check whether anyone is listening. You state what changed.
`MCPServer` serves `subscriptions/listen` for you. The wire obligations (the acknowledgment as the first frame, per-stream filtering, the subscription id on every frame) are the SDK's job.
!!! check
On the wire, a stream whose filter named `board://sprint` looks like this after `complete_task` runs:
```json
{"method": "notifications/subscriptions/acknowledged",
"params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
{"method": "notifications/resources/updated",
"params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
```
Note what the update does *not* carry: the board. Every frame carries the listen request's JSON-RPC id under `_meta`, and that id is the subscription id. The client mints it: the Python `Client` uses strings like `"listen-1"`; other clients may use integers.
## Only what was asked for
The filter is a contract. A stream that requested tool-list changes and one resource URI receives those two kinds and nothing else. Publish a prompt change and that stream stays silent.
`MCPServer` matches resource URIs as exact strings, so a stream that named `board://sprint` hears nothing about `board://sprint/tasks/1`. The spec lets a server report a change on a sub-resource of a subscribed URI; `MCPServer` never does, but clients are built to expect it.
Two things the stream is *not*:
* **It is not a replay log.** A dropped stream is gone, and events published while nobody was connected are not queued. Clients re-listen and refetch.
* **It is not the 2025 path.** Clients that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)`. The `notify_*` methods reach `subscriptions/listen` streams only.
!!! warning
Don't publish sensitive per-user URIs through `notify_resource_updated` on a multi-tenant
server. Any client may name any URI in its filter, and `MCPServer` honors it. The exposure
is narrow but real: a subscriber learns that a URI it can guess changed, and when. It never
learns content, and it cannot probe what exists, because an unknown URI is honored too and
simply never fires. To narrow the filter per client today, serve the method with your own
handler on the low-level `Server` and acknowledge a smaller filter than the client asked
for; the acknowledgment is how the client learns what it actually got.
!!! warning "Streamable HTTP only, for now"
`subscriptions/listen` needs a transport that can stream a request's response, which today
means streamable HTTP. Over stdio a 2026-07-28 connection rejects the method with
METHOD_NOT_FOUND, even though `server/discover` advertises the subscription capabilities
there. Serving it over stdio is planned; the open-stream semantics for that transport are
not built yet.
## The client end
Here is a client on the other side of that stream, following the board:
```python title="client.py" hl_lines="16"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
Entering `client.listen(...)` sends the request and waits for your acknowledgment, so the stream is live when the block starts, and each typed event is a cue to refetch, never a payload. That is the whole contract in one screen. Everything else about the client end lives on its own page: watching beside a main flow, stream endings, and re-listening. See **[Subscriptions](../client/subscriptions.md)** under *Clients*.
## Scaling past one process
Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer, because then a client's stream is pinned to one replica, and a publish on another replica has to reach it.
That seam is yours to implement: two methods over your pub/sub backend.
```python
from collections.abc import Callable
from redis.asyncio import Redis
from mcp.server.mcpserver import MCPServer
from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class
class RedisSubscriptionBus:
def __init__(self, redis: Redis) -> None:
self._redis = redis
self._listeners: dict[object, Callable[[ServerEvent], None]] = {}
async def publish(self, event: ServerEvent) -> None:
await self._redis.publish("mcp-events", encode(event)) # to every replica
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
token = object()
self._listeners[token] = listener
def unsubscribe() -> None:
self._listeners.pop(token, None)
return unsubscribe
mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis))
```
`encode` is yours, and so is the reader task on each replica that decodes arriving messages and calls every registered listener. Listeners are synchronous, must not raise, and run on the server's event loop.
The bus carries typed `ServerEvent` values, four small dataclasses, never JSON-RPC. Stamping, filtering, and stream lifecycles stay in the SDK, so a bus implementation cannot break the protocol. It can only move events between processes.
To publish from outside a request, construct the bus yourself so you hold the reference. `MCPServer` builds one internally when you pass nothing, and does not expose it.
```python
from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged
bus = InMemorySubscriptionBus()
mcp = MCPServer("Sprint Board", subscriptions=bus)
async def tools_reloaded() -> None:
await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere
```
## The low-level composition
Down on the low-level `Server` there is no pre-wired anything, and the same parts assemble in three lines:
```python title="server.py" hl_lines="9-10 48"
--8<-- "docs_src/subscriptions/tutorial002.py"
```
* You own the bus, so you publish to it directly: `await bus.publish(ResourceUpdated(uri=...))`. Put it wherever your handlers can reach it: module scope here, the lifespan in a bigger app.
* `ListenHandler(bus)` is the same handler `MCPServer` registers, and `on_subscriptions_listen=` is an ordinary handler slot. Put your own callable in that slot for different semantics, and the spec obligations move to you: acknowledge first, stamp every frame with the subscription id, deliver nothing outside the filter.
* `ListenHandler.close()` ends every open stream gracefully. Each one receives the listen request's result as its final frame, which is the spec's way of saying the server ended the subscription deliberately. It returns before those streams finish flushing, so give them a moment before you tear the transport down. Without it, streams end when the client disconnects.
## Recap
* A client opts in with one `subscriptions/listen` request, and the response is the stream. Serving it is built in.
* You publish with `ctx.notify_*`, and the SDK does the stamping, filtering, and lifecycle work.
* Events are cues, not payloads. Both ends refetch.
* The client end is `async with client.listen(...)`: **[Subscriptions](../client/subscriptions.md)** under *Clients* is that story.
* On the low-level `Server` you assemble the same parts yourself: a bus, `ListenHandler(bus)`, the `on_subscriptions_listen` slot.
* Scaling out means implementing `SubscriptionBus`, two methods, and passing it as `MCPServer(subscriptions=...)`.
Running the server that serves all this, behind one replica or twenty, is **[Deploy & scale](../run/deploy.md)**.