From db88ae00ed70c302a6db496e0a09ad76a20cad1f Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 15:20:34 +0000
Subject: [PATCH 01/15] docs: raise the mkdocs-material floor to 9.7.0, add
mkdocs-redirects
mkdocs.yml has enabled `navigation.path` (the breadcrumb trail) since the
docs landed, but it is a mkdocs-material 9.7.0 feature: the current
`>=9.5.45` floor lets a fresh resolve land on a 9.5/9.6 release that
silently drops it. Raise the floor to what the config actually needs.
There is currently no redirect machinery in these docs at all, so any page
rename 404s every existing inbound link, including the ones in the
published llms.txt. Add mkdocs-redirects so renames can carry a redirect
map. It is inert until a `redirect_maps` is configured; nothing in this
branch renames a page.
---
pyproject.toml | 3 ++-
uv.lock | 16 +++++++++++++++-
2 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 7b947588f..7603367ed 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -80,7 +80,8 @@ docs = [
"mkdocs-gen-files>=0.5.0",
"mkdocs-glightbox>=0.4.0",
"mkdocs-literate-nav>=0.6.1",
- "mkdocs-material[imaging]>=9.5.45",
+ "mkdocs-material[imaging]>=9.7.0",
+ "mkdocs-redirects>=1.2.2",
"mkdocstrings-python>=2.0.1",
]
codegen = ["datamodel-code-generator==0.57.0"]
diff --git a/uv.lock b/uv.lock
index a1e8a7e35..ef9efb005 100644
--- a/uv.lock
+++ b/uv.lock
@@ -964,6 +964,7 @@ docs = [
{ name = "mkdocs-glightbox" },
{ name = "mkdocs-literate-nav" },
{ name = "mkdocs-material", extra = ["imaging"] },
+ { name = "mkdocs-redirects" },
{ name = "mkdocstrings-python" },
]
@@ -1020,7 +1021,8 @@ docs = [
{ name = "mkdocs-gen-files", specifier = ">=0.5.0" },
{ name = "mkdocs-glightbox", specifier = ">=0.4.0" },
{ name = "mkdocs-literate-nav", specifier = ">=0.6.1" },
- { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5.45" },
+ { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.7.0" },
+ { name = "mkdocs-redirects", specifier = ">=1.2.2" },
{ name = "mkdocstrings-python", specifier = ">=2.0.1" },
]
@@ -1628,6 +1630,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
]
+[[package]]
+name = "mkdocs-redirects"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mkdocs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f1/a8/6d44a6cf07e969c7420cb36ab287b0669da636a2044de38a7d2208d5a758/mkdocs_redirects-1.2.2.tar.gz", hash = "sha256:3094981b42ffab29313c2c1b8ac3969861109f58b2dd58c45fc81cd44bfa0095", size = 7162, upload-time = "2024-11-07T14:57:21.109Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c4/ec/38443b1f2a3821bbcb24e46cd8ba979154417794d54baf949fefde1c2146/mkdocs_redirects-1.2.2-py3-none-any.whl", hash = "sha256:7dbfa5647b79a3589da4401403d69494bd1f4ad03b9c15136720367e1f340ed5", size = 6142, upload-time = "2024-11-07T14:57:19.143Z" },
+]
+
[[package]]
name = "mkdocstrings"
version = "0.30.0"
From 8172ab6c21c276d463ea61327272aac3c81a34ba Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 15:20:35 +0000
Subject: [PATCH 02/15] docs: regroup the nav into topical sections
The docs render as one flat 15-chapter "Tutorial - User Guide" plus a
15-item "Advanced" grab-bag (whose sidebar heading is a dead label: it has
no index page), and the section a deploying user needs most, "Running your
server", has exactly one child, titled "ASGI".
Regroup the same 40 pages into sections a reader would actually scan for:
Get started install -> first server -> test it
Servers one page per thing a server exposes; Tools first
Inside your handler the Context, dependencies, and everything a
running handler can do
Running your server now also owns Authorization and OpenTelemetry
Clients now also owns OAuth, identity assertion,
connecting to multiple servers, and the cache
Advanced only the genuine escape hatches (5, was 15),
with a real, clickable index page
"Protocol versions" and "Deprecated features" become their own top-level
entries. The first is the one page that squarely explains the two protocol
eras and was buried as the last child of "The Client", where a server
author never looks. The second is the SEP-2577 retirement table, filed
dead last in "Advanced".
Not a single file moves. MkDocs nav sections, nav titles, and file paths
are three independent things, so this is an mkdocs.yml edit plus three
new ~200-word section index pages (docs/servers/, docs/handlers/,
docs/advanced/). Every existing URL, every `--8<--` include, and every
docs_src test is untouched, and Material's breadcrumbs follow the nav,
not the directory.
docs/tutorial/index.md is rewritten from a "how these docs are built"
meta page into the Get started doorway; its tested-examples promise is
kept. tests/test_examples.py gains the two new docs directories so their
fenced code blocks stay lint-covered, and AGENTS.md's description of how
the docs are organised is updated to match.
---
AGENTS.md | 7 ++++---
docs/advanced/index.md | 28 +++++++++++++++++++++++++
docs/handlers/index.md | 27 ++++++++++++++++++++++++
docs/servers/index.md | 30 +++++++++++++++++++++++++++
docs/tutorial/index.md | 26 +++++++++++------------
mkdocs.yml | 47 +++++++++++++++++++++++-------------------
tests/test_examples.py | 2 ++
7 files changed, 130 insertions(+), 37 deletions(-)
create mode 100644 docs/advanced/index.md
create mode 100644 docs/handlers/index.md
create mode 100644 docs/servers/index.md
diff --git a/AGENTS.md b/AGENTS.md
index 6c51e8981..bc764b9cc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -138,9 +138,10 @@ rather than adding new standalone sections.
## Documentation
When a change affects public API or user-visible behaviour, update the relevant
-page(s) under `docs/` in the same PR. Docs are organised by topic
-(`tutorial/`, `client/`, `run/`, `advanced/`) — find the page covering the
-feature you touched rather than adding a new one.
+page(s) under `docs/` in the same PR. Docs are organised by the `nav:` sections
+in `mkdocs.yml` (Get started, Servers, Inside your handler, Running your server,
+Clients, Advanced), not by the on-disk directory names — find the page covering
+the feature you touched in `mkdocs.yml` rather than adding a new one.
## Formatting & Type Checking
diff --git a/docs/advanced/index.md b/docs/advanced/index.md
new file mode 100644
index 000000000..4eba7558a
--- /dev/null
+++ b/docs/advanced/index.md
@@ -0,0 +1,28 @@
+# Advanced
+
+Everything an ordinary server or client needs has a topical home in the sections above.
+This section is the escape hatches — the things you reach for when `MCPServer`'s
+convenience layer is in the way:
+
+* **[The low-level Server](low-level-server.md)** — the class `MCPServer` is built on.
+ Hand-written schemas, `on_*` handlers, nothing checked for you, and custom JSON-RPC
+ methods of your own.
+* **[Pagination](pagination.md)** and **[Middleware](middleware.md)** — two things you
+ can *only* do on the low-level `Server`.
+* **[Extensions](extensions.md)** and **[MCP Apps](apps.md)** — the protocol's
+ extension surface: compose extension packages into a server, or write your own.
+
+A few things you might reasonably look for here live where you'd actually use them
+instead:
+
+* **Authorization** is under **[Running your server](../run/index.md)** — you protect a
+ server where you deploy it.
+* **OAuth**, **identity assertion**, connecting to **multiple servers**, and the
+ response **cache** are all under **[Clients](../client/index.md)**.
+* **Multi-round-trip requests** and **Subscriptions** are under
+ **[Inside your handler](../handlers/index.md)** — both are things a handler *does*.
+* **URI templates** is under **[Servers](../servers/index.md)**, next to Resources.
+* **[Protocol versions](../client/protocol-versions.md)** and
+ **[Deprecated features](deprecated.md)** each have their own top-level page.
+
+If you're not sure whether you need this section, you don't.
diff --git a/docs/handlers/index.md b/docs/handlers/index.md
new file mode 100644
index 000000000..911f42c3c
--- /dev/null
+++ b/docs/handlers/index.md
@@ -0,0 +1,27 @@
+# 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](../tutorial/context.md)** — the one extra parameter any handler can
+ ask for: the live request, its headers, its session, and most of the verbs below.
+* **[Dependencies](../tutorial/dependencies.md)** — parameters the model never sees,
+ filled in by your own functions with `Resolve`.
+* **[Lifespan](../tutorial/lifespan.md)** — 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 — **[Elicitation](../tutorial/elicitation.md)**, and
+ **[Multi-round-trip requests](../advanced/multi-round-trip.md)**, the 2026-07-28
+ pattern that carries it.
+* Report **[Progress](../tutorial/progress.md)** on something slow.
+* Write logs — to standard error, for whoever operates the server — with
+ **[Logging](../tutorial/logging.md)**.
+* Tell subscribed clients that something changed —
+ **[Subscriptions](../advanced/subscriptions.md)**.
+
+If you haven't registered a handler yet, start with
+**[Tools](../tutorial/tools.md)** — every page here assumes you have one.
diff --git a/docs/servers/index.md b/docs/servers/index.md
new file mode 100644
index 000000000..f2769ac74
--- /dev/null
+++ b/docs/servers/index.md
@@ -0,0 +1,30 @@
+# Servers
+
+An `MCPServer` exposes three primitives to a connected client. They differ by who
+decides to use them:
+
+* A **[tool](../tutorial/tools.md)** is an action the *model* picks and calls. This is
+ the page most people want first, and
+ **[Structured Output](../tutorial/structured-output.md)** is its reference companion:
+ everything about the shape of what a tool returns.
+* A **[resource](../tutorial/resources.md)** is read-only data the *application*
+ chooses to read. **[URI templates](../advanced/uri-templates.md)** is its reference
+ companion: the full addressing syntax and the path-safety rules.
+* A **[prompt](../tutorial/prompts.md)** is a message template a *person* invokes by
+ name, from a menu or a slash command.
+
+Around the three primitives, the rest of what a server declares:
+
+* **[Completions](../tutorial/completions.md)** — server-side autocomplete for prompt
+ and resource-template arguments.
+* **[Images, audio & icons](../tutorial/media.md)** — everything a tool can
+ return besides text, and the icons a client shows next to your server.
+* **[Handling errors](../tutorial/handling-errors.md)** — the difference between an
+ error the model can recover from and one it must never see.
+
+Every page here stands on its own; jump straight to the one you need. If you haven't
+built a server yet, start with **[First steps](../tutorial/first-steps.md)** instead.
+
+What happens *inside* the functions you register — the `Context`, dependency injection,
+asking the user for more input mid-call — is the next section,
+**[Inside your handler](../handlers/index.md)**.
diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md
index e7c7ba799..b0ef0bcf7 100644
--- a/docs/tutorial/index.md
+++ b/docs/tutorial/index.md
@@ -1,8 +1,8 @@
-# Tutorial - User Guide
+# Get started
-This tutorial shows you how to use the MCP Python SDK, step by step.
-
-Each section gradually builds on the previous ones, but it's written so you can go straight to any specific section to solve a specific problem. It also works as a future reference: you can come back to exactly the part you need.
+New to MCP, or new to this SDK? Start here. These pages take you from nothing to a
+working, tested server: [install the SDK](../installation.md), build your
+[first server](first-steps.md), and [test it](testing.md) with an in-memory client.
## Run the code
@@ -18,7 +18,7 @@ It is **HIGHLY encouraged** that you write (or copy) the code, edit it, and run
## You will not be guessing
-Every example in this tutorial is a complete file under [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) in the SDK's own repository, and every one of them is exercised by the SDK's test suite through an **in-memory client**:
+Every example in these docs is a complete file under [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) in the SDK's own repository, and every one of them is exercised by the SDK's test suite through an **in-memory client**:
```python
import pytest
@@ -38,14 +38,14 @@ No subprocess, no port, no transport. `Client(mcp)` connects to the server objec
If a change to the SDK breaks an example on one of these pages, CI goes red before the page does. The code you read here is the code that runs.
-You'll use this yourself in the [Testing](testing.md) chapter; it's how you test your own servers, too.
-
-## Install the SDK
-
-If you haven't yet, [install the SDK](../installation.md) first.
+You'll use this yourself in [Testing](testing.md); it's how you test your own servers, too.
-## Advanced User Guide
+## Where to go next
-There is also an **Advanced User Guide** you can read after this one.
+Once you have a server running, the rest of these docs are a reference, not a course.
+Every page stands on its own — jump straight to what you need:
-It builds on this tutorial, uses the same concepts, and teaches you the extra things: the low-level `Server`, middleware, authorization, the 2026-07-28 protocol negotiation. But you should read this first: everything in the Advanced guide assumes you know the basics.
+* What a server exposes — tools, resources, prompts — is **[Servers](../servers/index.md)**.
+* What's available inside the functions you register is **[Inside your handler](../handlers/index.md)**.
+* Getting it in front of clients — stdio, HTTP, your existing FastAPI app — is **[Running your server](../run/index.md)**.
+* Building the other side, an application that *uses* MCP servers, is **[Clients](../client/index.md)**.
diff --git a/mkdocs.yml b/mkdocs.yml
index fda764714..a3f3b8281 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -12,48 +12,53 @@ site_url: https://py.sdk.modelcontextprotocol.io/v2/
nav:
- MCP Python SDK: index.md
- - Installation: installation.md
- - Tutorial - User Guide:
+ - Get started:
- tutorial/index.md
+ - Installation: installation.md
- First steps: tutorial/first-steps.md
+ - Testing: tutorial/testing.md
+ - Servers:
+ - servers/index.md
- Tools: tutorial/tools.md
- Structured Output: tutorial/structured-output.md
- Resources: tutorial/resources.md
+ - URI templates: advanced/uri-templates.md
- Prompts: tutorial/prompts.md
+ - Completions: tutorial/completions.md
+ - "Images, audio & icons": tutorial/media.md
+ - Handling errors: tutorial/handling-errors.md
+ - Inside your handler:
+ - handlers/index.md
- The Context: tutorial/context.md
- Dependencies: tutorial/dependencies.md
- - Handling errors: tutorial/handling-errors.md
- Lifespan: tutorial/lifespan.md
- - Media: tutorial/media.md
- - Completions: tutorial/completions.md
- Elicitation: tutorial/elicitation.md
+ - Multi-round-trip requests: advanced/multi-round-trip.md
- Progress: tutorial/progress.md
- Logging: tutorial/logging.md
- - Testing: tutorial/testing.md
+ - Subscriptions: advanced/subscriptions.md
- Running your server:
- run/index.md
- - ASGI: run/asgi.md
- - The Client:
+ - Add to an existing app: run/asgi.md
+ - Authorization: advanced/authorization.md
+ - OpenTelemetry: advanced/opentelemetry.md
+ - Clients:
- client/index.md
- - Client callbacks: client/callbacks.md
- - Client transports: client/transports.md
- - Protocol versions: client/protocol-versions.md
+ - Callbacks: client/callbacks.md
+ - Transports: client/transports.md
+ - OAuth: advanced/oauth-clients.md
+ - Identity assertion: advanced/identity-assertion.md
+ - Multiple servers: advanced/session-groups.md
+ - Caching: advanced/caching.md
+ - Protocol versions: client/protocol-versions.md
+ - Deprecated features: advanced/deprecated.md
- Advanced:
- - Multi-round-trip requests: advanced/multi-round-trip.md
+ - advanced/index.md
- The low-level Server: advanced/low-level-server.md
- - URI templates: advanced/uri-templates.md
- Pagination: advanced/pagination.md
- - Caching hints: advanced/caching.md
- - Subscriptions: advanced/subscriptions.md
- Middleware: advanced/middleware.md
- Extensions: advanced/extensions.md
- MCP Apps: advanced/apps.md
- - OpenTelemetry: advanced/opentelemetry.md
- - Authorization: advanced/authorization.md
- - OAuth clients: advanced/oauth-clients.md
- - Identity assertion: advanced/identity-assertion.md
- - Session groups: advanced/session-groups.md
- - Deprecated features: advanced/deprecated.md
- Migration Guide: migration.md
- API Reference: api/
diff --git a/tests/test_examples.py b/tests/test_examples.py
index f139f418a..dba562408 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -104,6 +104,8 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"docs/index.md",
"docs/installation.md",
"docs/tutorial",
+ "docs/servers",
+ "docs/handlers",
"docs/run",
"docs/client",
"docs/advanced",
From da2d53edaef0d26f65da9cba8884fee3af9e4ca7 Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 15:21:06 +0000
Subject: [PATCH 03/15] docs: retitle hand-offs and link text to match the new
nav
The pages chain into each other with end-of-page "Next: ..." hand-offs.
Under the regrouped nav some of those pointed backward, or across a
section boundary, or at a page by a title it no longer carries. Fix each
one to hand off to the page that actually follows it in its section, or
to the next section where it is the last page.
Also:
- "ASGI" (the page H1 and every link to it) becomes "Add to an existing
app". The old title named a Python interface standard; the page's
content is mounting into an existing Starlette/FastAPI app, the
DNS-rebinding 421, and CORS -- none of which anyone finds under the
word "ASGI". (#1798 is literally a user asking for a "Guide" to
content that already exists on that page.)
- The landing page and README stop calling the docs "the tutorial"; the
section they named is now "Get started" and the body of the docs is a
reference, not a course.
- Three sentences that said "this tutorial" now say "these docs"; there
is no longer a tutorial for them to be in.
- A pre-existing factual error on completions.md is fixed while its
closing line is retargeted: completions apply to prompt arguments and
resource-template parameters, never to a tool's, but the sentence said
"Suggestions help before a tool runs."
---
README.md | 4 ++--
docs/advanced/multi-round-trip.md | 2 +-
docs/advanced/opentelemetry.md | 2 +-
docs/index.md | 2 +-
docs/run/asgi.md | 2 +-
docs/run/index.md | 6 +++---
docs/tutorial/completions.md | 2 +-
docs/tutorial/dependencies.md | 2 +-
docs/tutorial/elicitation.md | 2 +-
docs/tutorial/first-steps.md | 6 +++---
docs/tutorial/handling-errors.md | 2 +-
docs/tutorial/lifespan.md | 2 +-
docs/tutorial/logging.md | 4 ++--
docs/tutorial/media.md | 2 +-
docs/tutorial/prompts.md | 2 +-
docs/tutorial/resources.md | 2 +-
docs/tutorial/testing.md | 4 ++--
17 files changed, 24 insertions(+), 24 deletions(-)
diff --git a/README.md b/README.md
index 0c0876bb6..2a50bc5ec 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@
**The documentation lives at .**
-It has the full [tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/).
+It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/v2/tutorial/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/).
## What is MCP?
@@ -82,7 +82,7 @@ Call `add` with `a=1`, `b=2` and you get `3` back.
Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring.
-[The tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/) takes it from here.
+[Get started](https://py.sdk.modelcontextprotocol.io/v2/tutorial/) takes it from here.
## A client in 10 lines
diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md
index 62734b38f..0bd5b1e00 100644
--- a/docs/advanced/multi-round-trip.md
+++ b/docs/advanced/multi-round-trip.md
@@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t
## The server side
-On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user and the SDK returns the `InputRequiredResult` for you - that form is the **[Dependencies](../tutorial/dependencies.md)** tutorial. 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:
+On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user and the SDK returns the `InputRequiredResult` for you - that form is the **[Dependencies](../tutorial/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"
diff --git a/docs/advanced/opentelemetry.md b/docs/advanced/opentelemetry.md
index 80fb83f04..1193a6e8b 100644
--- a/docs/advanced/opentelemetry.md
+++ b/docs/advanced/opentelemetry.md
@@ -104,4 +104,4 @@ mcp._lowlevel_server.middleware[:] = [
with no change to your server.
* Client-to-server trace context propagates automatically when both sides run the SDK.
-Next, the thing that decides whether a request runs at all: **[Authorization](authorization.md)**.
+The thing that decides whether a request runs at all is **[Authorization](authorization.md)**.
diff --git a/docs/index.md b/docs/index.md
index fe700a0af..837474731 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -89,7 +89,7 @@ You wrote two Python functions with type hints and a docstring. The SDK does the
## Where to go next
-* The **[Tutorial](tutorial/index.md)** walks through everything a server can do, one small step at a time.
+* **[Get started](tutorial/index.md)** takes you from install to a working, tested server.
* 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.
* Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format:
diff --git a/docs/run/asgi.md b/docs/run/asgi.md
index c72becb88..2a1e95e50 100644
--- a/docs/run/asgi.md
+++ b/docs/run/asgi.md
@@ -1,4 +1,4 @@
-# ASGI
+# Add to an existing app
`mcp.run("streamable-http")` starts a web server for you. Sometimes you don't want that: your MCP server is one piece of a larger web application, or you already have an ASGI deployment.
diff --git a/docs/run/index.md b/docs/run/index.md
index aafb1f333..0c38dcf74 100644
--- a/docs/run/index.md
+++ b/docs/run/index.md
@@ -67,7 +67,7 @@ Each transport has its own keyword arguments, all on `run()`:
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
* `json_response=True`: answer with plain JSON instead of an SSE stream.
* `stateless_http=True`: a fresh transport per request, no session tracking.
-* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[ASGI](asgi.md)** covers `transport_security`.
+* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[Add to an existing app](asgi.md)** covers `transport_security`.
!!! warning
Transport options go to `run()`, **not** to `MCPServer(...)`. The constructor describes what
@@ -78,7 +78,7 @@ Each transport has its own keyword arguments, all on `run()`:
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'
```
-`run()` is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is **[ASGI](asgi.md)**.
+`run()` is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is **[Add to an existing app](asgi.md)**.
## Server settings
@@ -143,4 +143,4 @@ uv run mcp install server.py -v API_KEY=abc123 -f .env
* `mcp dev` for the Inspector, `mcp run` to execute a file, `mcp install` for Claude Desktop, `mcp version` for the version.
* The transport never changes what your server *is*: all three files on this page expose the identical tool.
-When `run()` itself is the limit (your server inside an app that already exists), the next step is **[ASGI](asgi.md)**.
+When `run()` itself is the limit (your server inside an app that already exists), the next step is **[Add to an existing app](asgi.md)**.
diff --git a/docs/tutorial/completions.md b/docs/tutorial/completions.md
index 31cc8f082..afd3c4bef 100644
--- a/docs/tutorial/completions.md
+++ b/docs/tutorial/completions.md
@@ -122,4 +122,4 @@ Drop `context_arguments=` and the same call returns `[]`. The handler can't know
* `context.arguments` holds the already-resolved values; the client supplies them as `context_arguments=`.
* The `completions` capability appears the moment you register the handler. Without it, the request is `Method not found`.
-Suggestions help *before* a tool runs. To ask the user a question in the *middle* of one, you want **[Elicitation](elicitation.md)**.
+Suggestions help while the user is still *filling in* a prompt or template; to ask them a question in the *middle* of a tool call, you want **[Elicitation](elicitation.md)**. Next: everything a tool can return besides text, in **[Images, audio & icons](media.md)**.
diff --git a/docs/tutorial/dependencies.md b/docs/tutorial/dependencies.md
index 8d6d91412..725cf9cd0 100644
--- a/docs/tutorial/dependencies.md
+++ b/docs/tutorial/dependencies.md
@@ -142,4 +142,4 @@ That's the right default for a precondition: no answer, no order. When declining
* 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.
-Next: what happens when your tool fails, and how to choose who finds out, in **[Handling errors](handling-errors.md)**.
+Next: state your server builds once at startup, and how a handler reaches it, in the **[Lifespan](lifespan.md)**.
diff --git a/docs/tutorial/elicitation.md b/docs/tutorial/elicitation.md
index 7bd27a78a..d9e016236 100644
--- a/docs/tutorial/elicitation.md
+++ b/docs/tutorial/elicitation.md
@@ -169,4 +169,4 @@ Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit
* 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](../advanced/multi-round-trip.md)**.
-A tool that can ask is good. A tool that says how far along it is (**[Progress](progress.md)**) is next.
+Everything underneath that return — the retry loop, protecting `requestState`, driving it yourself — is **[Multi-round-trip requests](../advanced/multi-round-trip.md)**.
diff --git a/docs/tutorial/first-steps.md b/docs/tutorial/first-steps.md
index 5328d12be..a9f29e8f2 100644
--- a/docs/tutorial/first-steps.md
+++ b/docs/tutorial/first-steps.md
@@ -113,8 +113,8 @@ That dictionary is the server's half of the handshake:
Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **[Completions](completions.md)** proves it.
!!! info
- `Client(mcp)` is the same in-memory client every example in this tutorial is tested with, and
- it's how you'll test yours. It gets a whole chapter: **[Testing](testing.md)**.
+ `Client(mcp)` is the same in-memory client every example in these docs is tested with, and
+ it's how you'll test yours. It gets a whole page: **[Testing](testing.md)**.
## What you did not write
@@ -136,4 +136,4 @@ That ratio is the whole point of the SDK.
* The server's **capabilities** are declared for you, and a client only asks for what a server declares.
* `Client(mcp)` connects to the server object in memory: your test harness from day one.
-Each primitive now gets its own chapter, starting with the one the model drives: **[Tools](tools.md)**.
+Next: **[Testing](testing.md)** — one page, one in-memory client, and you're never guessing whether it works. Then each primitive gets its own page, starting with the one the model drives: **[Tools](tools.md)**.
diff --git a/docs/tutorial/handling-errors.md b/docs/tutorial/handling-errors.md
index 90efddc24..0b2aca23b 100644
--- a/docs/tutorial/handling-errors.md
+++ b/docs/tutorial/handling-errors.md
@@ -129,4 +129,4 @@ It means a whole class of `raise` statements you don't write: don't re-validate
* Bad arguments are rejected against the schema before your function runs; you don't `raise` for those.
* `from mcp import MCPError`; the error-code constants come from `mcp_types`.
-Errors handled. Next: the things your server sets up once, before the first call ever arrives, the **[Lifespan](lifespan.md)**.
+Errors handled. That is everything a server *exposes*. What every handler can read, and do back to the client while it runs, is the next section: **[Inside your handler](../handlers/index.md)**.
diff --git a/docs/tutorial/lifespan.md b/docs/tutorial/lifespan.md
index 796462f93..18d540bc0 100644
--- a/docs/tutorial/lifespan.md
+++ b/docs/tutorial/lifespan.md
@@ -99,4 +99,4 @@ Strip the server down to the lifecycle: give `Database` a `connected` flag, flip
* `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`.
-Next: tools that return more than text, **[Media](media.md)**.
+Next: a handler that stops mid-call to ask the user for something only they know, in **[Elicitation](elicitation.md)**.
diff --git a/docs/tutorial/logging.md b/docs/tutorial/logging.md
index bea34f1c9..23519c241 100644
--- a/docs/tutorial/logging.md
+++ b/docs/tutorial/logging.md
@@ -2,7 +2,7 @@
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 this tutorial doesn't teach it. The full list of what's deprecated and what to do instead is in **[Deprecated features](../advanced/deprecated.md)**.
+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](../advanced/deprecated.md)**.
What you do instead is what you do in every other Python program: the standard library.
@@ -75,4 +75,4 @@ went to standard error: the terminal, not the wire.
* 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.
-Next: the in-memory client that has been running every example on these pages, and how to point it at your own server, in **[Testing](testing.md)**.
+Next: telling connected clients that something on your server changed — the tool list, a resource — with **[Subscriptions](../advanced/subscriptions.md)**.
diff --git a/docs/tutorial/media.md b/docs/tutorial/media.md
index 06fde1608..ae2505572 100644
--- a/docs/tutorial/media.md
+++ b/docs/tutorial/media.md
@@ -105,4 +105,4 @@ A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `
* An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`.
* `icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects.
-That is everything a tool can put *into* a result. Helping the user fill in a prompt's or a resource template's arguments *before* anything runs is **[Completions](completions.md)**.
+That is everything a tool can put *into* a result. What happens when a tool *fails* — and who should find out — is **[Handling errors](handling-errors.md)**.
diff --git a/docs/tutorial/prompts.md b/docs/tutorial/prompts.md
index c512e96ff..f966c32e2 100644
--- a/docs/tutorial/prompts.md
+++ b/docs/tutorial/prompts.md
@@ -147,4 +147,4 @@ The `prompts/list` entry now carries everything a client needs to draw a good fo
* `title=` and `Field(description=...)` are what a client puts in its UI.
* A missing required argument fails the whole request. There is no per-prompt error result.
-Next up: the one extra parameter a tool, resource or prompt can ask the SDK for, **[The Context](context.md)**.
+Next up: server-side autocomplete for a prompt's (or a resource template's) arguments, in **[Completions](completions.md)**.
diff --git a/docs/tutorial/resources.md b/docs/tutorial/resources.md
index 8c63053a1..badd167f0 100644
--- a/docs/tutorial/resources.md
+++ b/docs/tutorial/resources.md
@@ -138,4 +138,4 @@ A client can also **subscribe** to a resource and be notified when it changes; t
* `str` becomes text, `bytes` becomes a base64 blob, anything else becomes JSON text. `mime_type=` is how you label it.
* Tools are for the model to act. Resources are for the application to read.
-Next: the third primitive, the one a person picks from a menu, **[Prompts](prompts.md)**.
+The third primitive, the one a person picks from a menu, is **[Prompts](prompts.md)**.
diff --git a/docs/tutorial/testing.md b/docs/tutorial/testing.md
index f5fe16765..1d7cf40c3 100644
--- a/docs/tutorial/testing.md
+++ b/docs/tutorial/testing.md
@@ -98,9 +98,9 @@ Leave it on in tests. It has no meaning in production code.
there: a legacy connection never sanitises in the first place, and the flag re-raises the
failure inside the server task instead of in your test.
-That one line is also why the rest of this tutorial can promise you that its examples work: every
+That one line is also why these docs can promise you that their examples work: every
example file is exercised by the SDK's own test suite through exactly this client. You're using the
same tool the SDK uses on itself.
-The tutorial ends here. Putting your tested server in front of a real client, over a real
+You have a working, tested server. Putting it in front of a real client, over a real
transport, is **[Running your server](../run/index.md)**.
From 7194d7b12553b5dce487c63fe5fd94b8e3208c97 Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 15:43:57 +0000
Subject: [PATCH 04/15] docs: move each page into the directory of its nav
section
The nav regroup put pages from docs/tutorial/ under "Servers", pages from
docs/advanced/ under "Clients", and so on -- the sidebar was right but the
on-disk layout and every URL still described the old grouping. Move each
of the 28 affected pages so its directory matches its section, under one
rule: the directory follows the section and the filename stem never
changes. docs/tutorial/ is gone.
Every one of the 28 old URLs gets an entry in mkdocs-redirects'
redirect_maps, so nothing 404s; under `strict: true` a stale redirect
target is itself a build failure, and the rendered redirect stubs were
spot-checked. Every relative inter-page link is recomputed for the pages'
new locations, and the strict build (which fails on any broken link, nav
path, or anchor) validates all of them.
The other things that reference the moved paths move in lockstep:
- The tests/docs_src/ module docstrings, two example READMEs, and
RELEASE.md all name docs pages by path.
- tests/test_examples.py's find_examples() directory list is rewritten
for the new layout, and gains the two pages that are now at the docs
root (protocol-versions.md, deprecated.md) and would otherwise
silently lose the inline-code-block lint coverage.
None of docs_src/ moves: the `--8<--` snippet includes are repo-root-
relative, so the 120 tested example files and their tests are untouched.
The "Inside your handler" section index also drops an over-broad claim
while it is being moved into: Elicitation and Multi-round-trip requests
are not Context verbs (Resolve is an annotated parameter and MRTR is a
return value; only the legacy `ctx.elicit` path touches Context), so the
Context bullet now names only the progress and change-notification verbs
it actually carries.
---
RELEASE.md | 2 +-
docs/advanced/index.md | 4 +-
docs/advanced/low-level-server.md | 16 ++--
docs/advanced/middleware.md | 6 +-
docs/{advanced => client}/caching.md | 2 +-
docs/client/callbacks.md | 12 +--
.../identity-assertion.md | 8 +-
docs/client/index.md | 12 +--
docs/{advanced => client}/oauth-clients.md | 6 +-
docs/{advanced => client}/session-groups.md | 4 +-
docs/client/transports.md | 6 +-
docs/{advanced => }/deprecated.md | 12 +--
docs/{tutorial => get-started}/first-steps.md | 4 +-
docs/{tutorial => get-started}/index.md | 2 +-
docs/{ => get-started}/installation.md | 0
docs/{tutorial => get-started}/testing.md | 2 +-
docs/{tutorial => handlers}/context.md | 2 +-
docs/{tutorial => handlers}/dependencies.md | 4 +-
docs/{tutorial => handlers}/elicitation.md | 8 +-
docs/handlers/index.md | 21 ++---
docs/{tutorial => handlers}/lifespan.md | 0
docs/{tutorial => handlers}/logging.md | 6 +-
.../multi-round-trip.md | 12 +--
docs/{tutorial => handlers}/progress.md | 2 +-
docs/{advanced => handlers}/subscriptions.md | 0
docs/index.md | 4 +-
docs/migration.md | 10 +--
docs/{client => }/protocol-versions.md | 6 +-
docs/{advanced => run}/authorization.md | 6 +-
docs/run/index.md | 2 +-
docs/{advanced => run}/opentelemetry.md | 2 +-
docs/{tutorial => servers}/completions.md | 4 +-
docs/{tutorial => servers}/handling-errors.md | 2 +-
docs/servers/index.md | 18 ++--
docs/{tutorial => servers}/media.md | 0
docs/{tutorial => servers}/prompts.md | 0
docs/{tutorial => servers}/resources.md | 4 +-
.../structured-output.md | 0
docs/{tutorial => servers}/tools.md | 0
docs/{advanced => servers}/uri-templates.md | 10 +--
examples/stories/subscriptions/README.md | 2 +-
mkdocs.yml | 86 +++++++++++++------
tests/client/test_client_caching.py | 2 +-
tests/docs_src/test_authorization.py | 2 +-
tests/docs_src/test_caching.py | 2 +-
tests/docs_src/test_completions.py | 2 +-
tests/docs_src/test_context.py | 2 +-
tests/docs_src/test_dependencies.py | 2 +-
tests/docs_src/test_deprecated.py | 2 +-
tests/docs_src/test_elicitation.py | 2 +-
tests/docs_src/test_first_steps.py | 2 +-
tests/docs_src/test_handling_errors.py | 2 +-
tests/docs_src/test_identity_assertion.py | 2 +-
tests/docs_src/test_lifespan.py | 2 +-
tests/docs_src/test_logging.py | 2 +-
tests/docs_src/test_media.py | 2 +-
tests/docs_src/test_mrtr.py | 2 +-
tests/docs_src/test_oauth_clients.py | 2 +-
tests/docs_src/test_opentelemetry.py | 2 +-
tests/docs_src/test_progress.py | 2 +-
tests/docs_src/test_prompts.py | 2 +-
tests/docs_src/test_protocol_versions.py | 2 +-
tests/docs_src/test_resources.py | 2 +-
tests/docs_src/test_session_groups.py | 2 +-
tests/docs_src/test_structured_output.py | 2 +-
tests/docs_src/test_subscriptions.py | 2 +-
tests/docs_src/test_testing.py | 2 +-
tests/docs_src/test_tools.py | 2 +-
tests/docs_src/test_uri_templates.py | 2 +-
tests/server/test_caching.py | 2 +-
tests/test_examples.py | 5 +-
71 files changed, 201 insertions(+), 169 deletions(-)
rename docs/{advanced => client}/caching.md (98%)
rename docs/{advanced => client}/identity-assertion.md (96%)
rename docs/{advanced => client}/oauth-clients.md (97%)
rename docs/{advanced => client}/session-groups.md (93%)
rename docs/{advanced => }/deprecated.md (87%)
rename docs/{tutorial => get-started}/first-steps.md (98%)
rename docs/{tutorial => get-started}/index.md (96%)
rename docs/{ => get-started}/installation.md (100%)
rename docs/{tutorial => get-started}/testing.md (98%)
rename docs/{tutorial => handlers}/context.md (98%)
rename docs/{tutorial => handlers}/dependencies.md (98%)
rename docs/{tutorial => handlers}/elicitation.md (97%)
rename docs/{tutorial => handlers}/lifespan.md (100%)
rename docs/{tutorial => handlers}/logging.md (94%)
rename docs/{advanced => handlers}/multi-round-trip.md (94%)
rename docs/{tutorial => handlers}/progress.md (98%)
rename docs/{advanced => handlers}/subscriptions.md (100%)
rename docs/{client => }/protocol-versions.md (97%)
rename docs/{advanced => run}/authorization.md (97%)
rename docs/{advanced => run}/opentelemetry.md (97%)
rename docs/{tutorial => servers}/completions.md (96%)
rename docs/{tutorial => servers}/handling-errors.md (98%)
rename docs/{tutorial => servers}/media.md (100%)
rename docs/{tutorial => servers}/prompts.md (100%)
rename docs/{tutorial => servers}/resources.md (97%)
rename docs/{tutorial => servers}/structured-output.md (100%)
rename docs/{tutorial => servers}/tools.md (100%)
rename docs/{advanced => servers}/uri-templates.md (96%)
diff --git a/RELEASE.md b/RELEASE.md
index cfd4d927c..28a108f75 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -35,7 +35,7 @@ the publish job — `skip-existing` makes it skip whatever already landed. The
1. Update the pre-release version examples in `README.md` and the docs
(grep the outgoing version — the pins live in the README Installation
- section, `docs/index.md`, and `docs/installation.md`) so the tagged
+ section, `docs/index.md`, and `docs/get-started/installation.md`) so the tagged
commit — and therefore the README PyPI publishes — names the version
being released. When entering a new phase (alpha → beta → rc), update
the banner wording too.
diff --git a/docs/advanced/index.md b/docs/advanced/index.md
index 4eba7558a..7e9ed6f97 100644
--- a/docs/advanced/index.md
+++ b/docs/advanced/index.md
@@ -22,7 +22,7 @@ instead:
* **Multi-round-trip requests** and **Subscriptions** are under
**[Inside your handler](../handlers/index.md)** — both are things a handler *does*.
* **URI templates** is under **[Servers](../servers/index.md)**, next to Resources.
-* **[Protocol versions](../client/protocol-versions.md)** and
- **[Deprecated features](deprecated.md)** each have their own top-level page.
+* **[Protocol versions](../protocol-versions.md)** and
+ **[Deprecated features](../deprecated.md)** each have their own top-level page.
If you're not sure whether you need this section, you don't.
diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md
index 123c85dd7..7ad391e3d 100644
--- a/docs/advanced/low-level-server.md
+++ b/docs/advanced/low-level-server.md
@@ -12,7 +12,7 @@ For everything else, stay on `MCPServer`.
## The same tool, by hand
-This is `search_books` from **[Tools](../tutorial/tools.md)** (the nine-line `@mcp.tool()` file) with the sugar removed:
+This is `search_books` from **[Tools](../servers/tools.md)** (the nine-line `@mcp.tool()` file) with the sugar removed:
```python title="server.py" hl_lines="23 27 33"
--8<-- "docs_src/lowlevel/tutorial001.py"
@@ -61,7 +61,7 @@ The same text the `@mcp.tool()` version produced. Two honest differences:
## Nothing is checked for you
-In **[Tools](../tutorial/tools.md)** you saw a bad argument get rejected before your function ran. That was `MCPServer` validating the call against the schema it generated.
+In **[Tools](../servers/tools.md)** you saw a bad argument get rejected before your function ran. That was `MCPServer` validating the call against the schema it generated.
`Server` does not do that. Your `input_schema` is *advertised* to the client; it is never *applied* to `params.arguments`.
@@ -72,9 +72,9 @@ In **[Tools](../tutorial/tools.md)** you saw a bad argument get rejected before
MCPError: Internal server error
```
- A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **[Testing](../tutorial/testing.md)**.)
+ A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **[Testing](../get-started/testing.md)**.)
-That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **[Handling errors](../tutorial/handling-errors.md)**.
+That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **[Handling errors](../servers/handling-errors.md)**.
## Two tools, one handler
@@ -106,7 +106,7 @@ Call it and the result carries both representations:
}
```
-The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../tutorial/structured-output.md)**.
+The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**.
## `_meta`: for the application, not the model
@@ -147,7 +147,7 @@ No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_promp
* The lifespan is a `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` on an `async` generator gives you exactly that.
* Whatever it `yield`s becomes `ctx.lifespan_context`, and because the handlers are annotated `ServerRequestContext[Catalog]`, `.search(...)` autocompletes and type-checks.
-* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **[Lifespan](../tutorial/lifespan.md)**.
+* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **[Lifespan](../handlers/lifespan.md)**.
Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`.
@@ -181,9 +181,9 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other
Each of these is one idea you now have the vocabulary for; each has its own chapter.
-* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**).
+* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**).
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
-* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](subscriptions.md)** for the full composition.
+* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition.
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.
## Recap
diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md
index 7cc15ce3c..4e57bae82 100644
--- a/docs/advanced/middleware.md
+++ b/docs/advanced/middleware.md
@@ -84,7 +84,7 @@ In increasing order of how much you should hesitate:
The SDK ships exactly one middleware, and it is already on your server's list: the one that
emits an OpenTelemetry span for every message. You don't append it, and most of the time you
don't think about it. It is a no-op until you install an exporter, and it has its own page:
-**[OpenTelemetry](opentelemetry.md)**.
+**[OpenTelemetry](../run/opentelemetry.md)**.
!!! info
If you have written ASGI middleware, you already know this shape. Starlette's
@@ -101,8 +101,8 @@ don't think about it. It is a no-op until you install an exporter, and it has it
* `ctx.request_id is None` is how you tell a notification from a request.
* Raise instead of calling `call_next` to refuse one message; the connection survives.
* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See
- **[OpenTelemetry](opentelemetry.md)**.
+ **[OpenTelemetry](../run/opentelemetry.md)**.
* The whole surface is provisional. Observe with it; don't build on it.
-That is everything that wraps a request. **[Authorization](authorization.md)** is what decides whether the request
+That is everything that wraps a request. **[Authorization](../run/authorization.md)** is what decides whether the request
gets to run at all.
diff --git a/docs/advanced/caching.md b/docs/client/caching.md
similarity index 98%
rename from docs/advanced/caching.md
rename to docs/client/caching.md
index ba979ccc1..68cb00c15 100644
--- a/docs/advanced/caching.md
+++ b/docs/client/caching.md
@@ -85,7 +85,7 @@ Cache keys also carry the **server's identity**: the URL string you dialed, with
### What the cache never does
* **Session-tier calls bypass it.** `client.session.list_tools()` and friends always make the round trip; the cache lives on the `Client` verbs.
-* **`server/discover` stays out of it.** The discover result is delivered once, at connect, and never enters the response cache, even when it carries a `ttlMs`. If you persist one yourself to skip the reconnect probe ([`prior_discover`](../client/protocol-versions.md#reconnecting-with-prior_discover)), its freshness is your bookkeeping: `DiscoverResult` carries `ttl_ms` and `cache_scope`, already parsed, for exactly that purpose.
+* **`server/discover` stays out of it.** The discover result is delivered once, at connect, and never enters the response cache, even when it carries a `ttlMs`. If you persist one yourself to skip the reconnect probe ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), its freshness is your bookkeeping: `DiscoverResult` carries `ttl_ms` and `cache_scope`, already parsed, for exactly that purpose.
* **Continuation pages are never cached.** Only cursor-less calls participate. A continuation page rejected for an expired cursor does *evict* the cached listing, because the listing changed under it.
* **Multi-round-trip reads are never cached.** A `read_resource` seeded with `input_responses`/`request_state`, or one that resolves through input rounds, never enters the cache (a spec MUST).
* **Notification eviction needs notifications.** Eviction is only as good as the transport's delivery, and the modern in-process path (`Client(server)` with the default `mode="auto"`) does not deliver standalone notifications today.
diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md
index 31a4d635b..809b18629 100644
--- a/docs/client/callbacks.md
+++ b/docs/client/callbacks.md
@@ -15,7 +15,7 @@ Here is a server whose tool can't finish on its own:
* `ctx.elicit(...)` sends an `elicitation/create` request **to the client** and waits.
* The tool doesn't return until somebody (a person in a form, or your code) supplies a `name`.
-That is the server half, and the **[Elicitation](../tutorial/elicitation.md)** chapter owns it. This chapter is the other end of the wire.
+That is the server half, and the **[Elicitation](../handlers/elicitation.md)** chapter owns it. This chapter is the other end of the wire.
## The elicitation callback
@@ -31,7 +31,7 @@ That is the server half, and the **[Elicitation](../tutorial/elicitation.md)** c
!!! tip
`params` is a union of the two elicitation modes. Here `params.mode` is `"form"`; a `"url"` request
carries `params.url` instead of a schema. One callback handles both; branch on `params.mode`.
- **[Elicitation](../tutorial/elicitation.md)** shows the full pattern.
+ **[Elicitation](../handlers/elicitation.md)** shows the full pattern.
### Try it
@@ -59,11 +59,11 @@ One `tools/call` from you, one `elicitation/create` back from the server, answer
protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit`
fails before your callback ever runs. The transport doesn't decide that; the negotiated
protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has
- to answer one; every test behind this page does. **[Protocol versions](protocol-versions.md)** has the whole story.
+ to answer one; every test behind this page does. **[Protocol versions](../protocol-versions.md)** has the whole story.
On a 2026-07-28 session the callback isn't dead, it's fed differently: when a tool returns an
`InputRequiredResult` carrying an `ElicitRequest`, `Client` dispatches that entry to the same
- `elicitation_callback` and retries the call for you. That flow is **[Multi-round-trip requests](../advanced/multi-round-trip.md)**.
+ `elicitation_callback` and retries the call for you. That flow is **[Multi-round-trip requests](../handlers/multi-round-trip.md)**.
## A callback is a capability
@@ -113,7 +113,7 @@ Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pas
`sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in.
-Both work. Both follow the rule above. And both serve RPCs the **2026-07-28 spec removes**: a modern server doesn't call back into your client mid-request, it hands the request back to you as part of the tool result (**[Multi-round-trip requests](../advanced/multi-round-trip.md)**). The callbacks themselves are not dead. When an `InputRequiredResult` carries a `CreateMessageRequest` or a `ListRootsRequest`, `Client`'s auto-loop dispatches it to the same `sampling_callback` or `list_roots_callback` you registered here. The whole list is in **[Deprecated features](../advanced/deprecated.md)**.
+Both work. Both follow the rule above. And both serve RPCs the **2026-07-28 spec removes**: a modern server doesn't call back into your client mid-request, it hands the request back to you as part of the tool result (**[Multi-round-trip requests](../handlers/multi-round-trip.md)**). The callbacks themselves are not dead. When an `InputRequiredResult` carries a `CreateMessageRequest` or a `ListRootsRequest`, `Client`'s auto-loop dispatches it to the same `sampling_callback` or `list_roots_callback` you registered here. The whole list is in **[Deprecated features](../deprecated.md)**.
You still need the callbacks to talk to servers that haven't moved. The signatures:
@@ -131,7 +131,7 @@ Pass them to `Client(...)` exactly like `elicitation_callback`.
Two more. Neither declares anything.
-`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../tutorial/logging.md)** has what to do instead), so this callback exists for the servers that still emit it.
+`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../handlers/logging.md)** has what to do instead), so this callback exists for the servers that still emit it.
`message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing.
diff --git a/docs/advanced/identity-assertion.md b/docs/client/identity-assertion.md
similarity index 96%
rename from docs/advanced/identity-assertion.md
rename to docs/client/identity-assertion.md
index 7e7318361..b5d03c1ea 100644
--- a/docs/advanced/identity-assertion.md
+++ b/docs/client/identity-assertion.md
@@ -4,11 +4,11 @@ Every provider in **[OAuth clients](oauth-clients.md)** starts by asking the MCP
An enterprise wants neither decided per server. It already runs an identity provider (Okta, Microsoft Entra ID, your own); the user already signed in to it this morning; and it is the one place the security team wants to decide who may reach what. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), the **Enterprise-Managed Authorization** extension, moves the decision there. The IdP signs a short-lived JWT, an **Identity Assertion JWT Authorization Grant**, the **ID-JAG**: a statement that *this user*, through *this client*, may reach *this MCP server*. The client trades it for an ordinary access token. No browser, no consent screen, no dynamic registration.
-This chapter is both ends of that trade. The MCP server itself never changes: it is still the resource server from **[Authorization](authorization.md)**, checking whatever token shows up.
+This chapter is both ends of that trade. The MCP server itself never changes: it is still the resource server from **[Authorization](../run/authorization.md)**, checking whatever token shows up.
## Two token requests
-Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **[Authorization](authorization.md)**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In the flows you already know, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first.
+Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **[Authorization](../run/authorization.md)**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In the flows you already know, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first.
The client makes one token request to each.
@@ -108,7 +108,7 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I
!!! info
A server that still embeds its authorization server with `auth_server_provider=` reaches the same
- code through `AuthSettings(identity_assertion_enabled=True)`. **[Authorization](authorization.md)** explains why new
+ code through `AuthSettings(identity_assertion_enabled=True)`. **[Authorization](../run/authorization.md)** explains why new
servers should not start there.
!!! check
@@ -143,4 +143,4 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I
* The authorization server is never discovered from the resource server. Configure `issuer` to exactly the string its metadata document serves; the comparison is character for character.
* Server side, `identity_assertion_enabled=True` plus `exchange_identity_assertion`. The SDK authenticates the client and gates the grant; validating the ID-JAG is entirely yours, and the issued token is bound to the ID-JAG's `resource`, not the request's.
-The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in **[Authorization](authorization.md)**.
+The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in **[Authorization](../run/authorization.md)**.
diff --git a/docs/client/index.md b/docs/client/index.md
index 7712e0620..2fef17068 100644
--- a/docs/client/index.md
+++ b/docs/client/index.md
@@ -35,7 +35,7 @@ Four read-only properties, populated the moment you enter the block:
* `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`.
* `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one.
-You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **[Protocol versions](protocol-versions.md)** has the whole story.
+You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **[Protocol versions](../protocol-versions.md)** has the whole story.
!!! tip
`client.session` is the underlying `ClientSession`, the low-level escape hatch.
@@ -104,7 +104,7 @@ That is why `main` narrows with `isinstance(block, TextContent)` before touching
`structured_content` is the tool's return value as JSON, matching the tool's declared `output_schema`. No string parsing, no guessing.
-When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **[Structured Output](../tutorial/structured-output.md)** chapter.
+When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **[Structured Output](../servers/structured-output.md)** chapter.
### `is_error`: whether the tool failed
@@ -129,7 +129,7 @@ A tool that raises does **not** raise in your client. It comes back as an ordina
(`call_tool("does_not_exist", {})`) and nothing raises. You get the same shape back,
`is_error=True` with `Unknown tool: does_not_exist` in `content`. A `Client` method raises
`MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and
- **[Handling errors](../tutorial/handling-errors.md)** covers when a server produces which.
+ **[Handling errors](../servers/handling-errors.md)** covers when a server produces which.
## Resources
@@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read.
`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`).
-A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there, and the server side of the story is **[Subscriptions](../advanced/subscriptions.md)**.
+A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there, and the server side of the story is **[Subscriptions](../handlers/subscriptions.md)**.
## Prompts
@@ -181,7 +181,7 @@ A server with a completion handler can autocomplete prompt and resource-template
* `ref` says *which* prompt or template you're filling in: a `PromptReference` or a `ResourceTemplateReference`.
* `argument` is `{"name": ..., "value": ...}`: the argument and what the user has typed so far.
-The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **[Completions](../tutorial/completions.md)** chapter.
+The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **[Completions](../servers/completions.md)** chapter.
## Pagination
@@ -197,7 +197,7 @@ This loop is correct against every server. `MCPServer` returns everything in one
`Client(mcp)` with no process and no port is already a test harness for your server.
-There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../tutorial/testing.md)** is the chapter that explains it and builds the whole pattern around it.
+There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../get-started/testing.md)** is the chapter that explains it and builds the whole pattern around it.
## Recap
diff --git a/docs/advanced/oauth-clients.md b/docs/client/oauth-clients.md
similarity index 97%
rename from docs/advanced/oauth-clients.md
rename to docs/client/oauth-clients.md
index 698a08f4f..3cfd57866 100644
--- a/docs/advanced/oauth-clients.md
+++ b/docs/client/oauth-clients.md
@@ -4,7 +4,7 @@ Some MCP servers are protected. Send them a request without a token and they ans
**`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx.Auth`, the standard httpx hook for "do something to every request". You attach it to an `httpx.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it.
-This chapter is the client side. Making your own server demand a token is **[Authorization](authorization.md)**.
+This chapter is the client side. Making your own server demand a token is **[Authorization](../run/authorization.md)**.
## The provider
@@ -70,7 +70,7 @@ A real client runs a small local HTTP server on the redirect URI instead of call
Look at `main()`. The provider goes on the **httpx client**, the httpx client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`.
-`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **[Client transports](../client/transports.md)**.
+`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **[Client transports](transports.md)**.
## What the provider does for you
@@ -136,4 +136,4 @@ Not everything is a flow error. The network can still fail; those are ordinary `
* `ClientCredentialsOAuthProvider` is the no-human version: `client_id` + `client_secret`, no handlers, no browser.
* Every OAuth failure is an `OAuthFlowError`; `OAuthRegistrationError` and `OAuthTokenError` are its subclasses.
-The other half of this handshake, making your *server* demand the token, is **[Authorization](authorization.md)**.
+The other half of this handshake, making your *server* demand the token, is **[Authorization](../run/authorization.md)**.
diff --git a/docs/advanced/session-groups.md b/docs/client/session-groups.md
similarity index 93%
rename from docs/advanced/session-groups.md
rename to docs/client/session-groups.md
index 952231b84..c7a1434fb 100644
--- a/docs/advanced/session-groups.md
+++ b/docs/client/session-groups.md
@@ -68,7 +68,7 @@ If you already hold a connected `ClientSession` (`Client.session` is one), hand
## The classic handshake
-`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **[Protocol versions](../client/protocol-versions.md)**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better.
+`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **[Protocol versions](../protocol-versions.md)**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better.
## Recap
@@ -79,4 +79,4 @@ If you already hold a connected `ClientSession` (`Client.session` is one), hand
* `component_name_hook=` rewrites every registered name. The dict key changes, the wire name does not.
* `connect_with_session` adds a session you already hold; `disconnect_from_server` removes one.
-The handshake a group speaks (and the faster one a `Client` prefers) is the subject of **[Protocol versions](../client/protocol-versions.md)**.
+The handshake a group speaks (and the faster one a `Client` prefers) is the subject of **[Protocol versions](../protocol-versions.md)**.
diff --git a/docs/client/transports.md b/docs/client/transports.md
index 1503979a3..11b10285d 100644
--- a/docs/client/transports.md
+++ b/docs/client/transports.md
@@ -18,7 +18,7 @@ No subprocess, no port, no bytes on a wire. The client and the server are two ob
That makes it two things at once:
-* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../tutorial/testing.md)** chapter builds the whole pattern around it.
+* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../get-started/testing.md)** 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
@@ -68,7 +68,7 @@ Two things to notice:
!!! info
If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection
limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in:
- `httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](../advanced/oauth-clients.md)**.
+ `httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
## stdio
@@ -112,4 +112,4 @@ A **transport** is any async context manager that yields a `(read, write)` pair
* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol.
* Constructing a `Client` picks the transport. `async with` opens it.
-Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **[Protocol versions](protocol-versions.md)** is the page.
+Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **[Protocol versions](../protocol-versions.md)** is the page.
diff --git a/docs/advanced/deprecated.md b/docs/deprecated.md
similarity index 87%
rename from docs/advanced/deprecated.md
rename to docs/deprecated.md
index 18bcc7946..9b879f1f6 100644
--- a/docs/advanced/deprecated.md
+++ b/docs/deprecated.md
@@ -8,16 +8,16 @@ The table below names each deprecated feature, why it is going away, and the rep
| Deprecated | Why | What you do instead |
|---|---|---|
-| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **[Multi-round-trip requests](multi-round-trip.md)**). |
-| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **[Multi-round-trip requests](multi-round-trip.md)**). |
-| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **[Logging](../tutorial/logging.md)**). |
+| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **[Multi-round-trip requests](handlers/multi-round-trip.md)**). |
+| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **[Multi-round-trip requests](handlers/multi-round-trip.md)**). |
+| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **[Logging](handlers/logging.md)**). |
| **`ping`**: `client.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. It only works against a `mode="legacy"` connection. |
-| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **[Progress](../tutorial/progress.md)**). |
+| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **[Progress](handlers/progress.md)**). |
Three things fall out of that table:
* Roots, sampling, and logging go together. One proposal, **SEP-2577**, deprecates all three capabilities at once.
-* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **[Multi-round-trip requests](multi-round-trip.md)**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks.
+* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **[Multi-round-trip requests](handlers/multi-round-trip.md)**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks.
* `ping` is the odd one out. The protocol does not deprecate it, it removes it. The SDK method still warns (its message says *removed*, not *deprecated*) and calling it on a modern connection answers with *"Method not found"*.
## Deprecated is advisory
@@ -82,7 +82,7 @@ That is the whole API. There is no per-method switch, and you don't want one: th
## Recap
* The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), restricts **progress** to server-to-client, and removes **`ping`**.
-* The replacement column points you onward: **[Multi-round-trip requests](multi-round-trip.md)** for sampling and roots, **[Logging](../tutorial/logging.md)** for logging, **[Progress](../tutorial/progress.md)** for progress. `ping` needs nothing at all.
+* The replacement column points you onward: **[Multi-round-trip requests](handlers/multi-round-trip.md)** for sampling and roots, **[Logging](handlers/logging.md)** for logging, **[Progress](handlers/progress.md)** for progress. `ping` needs nothing at all.
* Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default).
* Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise.
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure.
diff --git a/docs/tutorial/first-steps.md b/docs/get-started/first-steps.md
similarity index 98%
rename from docs/tutorial/first-steps.md
rename to docs/get-started/first-steps.md
index a9f29e8f2..97bbbc069 100644
--- a/docs/tutorial/first-steps.md
+++ b/docs/get-started/first-steps.md
@@ -110,7 +110,7 @@ That dictionary is the server's half of the handshake:
`MCPServer` serves all three primitives, so all three are always declared.
-Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **[Completions](completions.md)** proves it.
+Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **[Completions](../servers/completions.md)** proves it.
!!! info
`Client(mcp)` is the same in-memory client every example in these docs is tested with, and
@@ -136,4 +136,4 @@ That ratio is the whole point of the SDK.
* The server's **capabilities** are declared for you, and a client only asks for what a server declares.
* `Client(mcp)` connects to the server object in memory: your test harness from day one.
-Next: **[Testing](testing.md)** — one page, one in-memory client, and you're never guessing whether it works. Then each primitive gets its own page, starting with the one the model drives: **[Tools](tools.md)**.
+Next: **[Testing](testing.md)** — one page, one in-memory client, and you're never guessing whether it works. Then each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**.
diff --git a/docs/tutorial/index.md b/docs/get-started/index.md
similarity index 96%
rename from docs/tutorial/index.md
rename to docs/get-started/index.md
index b0ef0bcf7..2e7b2799f 100644
--- a/docs/tutorial/index.md
+++ b/docs/get-started/index.md
@@ -1,7 +1,7 @@
# Get started
New to MCP, or new to this SDK? Start here. These pages take you from nothing to a
-working, tested server: [install the SDK](../installation.md), build your
+working, tested server: [install the SDK](installation.md), build your
[first server](first-steps.md), and [test it](testing.md) with an in-memory client.
## Run the code
diff --git a/docs/installation.md b/docs/get-started/installation.md
similarity index 100%
rename from docs/installation.md
rename to docs/get-started/installation.md
diff --git a/docs/tutorial/testing.md b/docs/get-started/testing.md
similarity index 98%
rename from docs/tutorial/testing.md
rename to docs/get-started/testing.md
index 1d7cf40c3..eaf8a338c 100644
--- a/docs/tutorial/testing.md
+++ b/docs/get-started/testing.md
@@ -79,7 +79,7 @@ Two different things can go wrong, and this flag only touches one of them.
An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with
`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or
without it, `call_tool` returns the same `is_error=True` result. There's a whole chapter on it:
-**[Handling errors](handling-errors.md)**.
+**[Handling errors](../servers/handling-errors.md)**.
A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the
server sanitises it into a generic `"Internal server error"` before the client sees it. You should
diff --git a/docs/tutorial/context.md b/docs/handlers/context.md
similarity index 98%
rename from docs/tutorial/context.md
rename to docs/handlers/context.md
index 96bdd0776..14a886da2 100644
--- a/docs/tutorial/context.md
+++ b/docs/handlers/context.md
@@ -104,7 +104,7 @@ What a server offers is not fixed at import time. Register a tool at runtime, th
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 — the `send_*` methods above do not reach those streams. The `Context` publish methods — `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, and `await ctx.notify_resource_updated(uri)` — deliver to every subscribed stream at once. The whole story, including scaling out across replicas, is in **[Subscriptions](../advanced/subscriptions.md)**.
+On a 2026-07-28 connection, clients receive change notifications only on a `subscriptions/listen` stream they opened — the `send_*` methods above do not reach those streams. The `Context` publish methods — `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, and `await ctx.notify_resource_updated(uri)` — deliver to every subscribed stream at once. 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
diff --git a/docs/tutorial/dependencies.md b/docs/handlers/dependencies.md
similarity index 98%
rename from docs/tutorial/dependencies.md
rename to docs/handlers/dependencies.md
index 725cf9cd0..2b01b3075 100644
--- a/docs/tutorial/dependencies.md
+++ b/docs/handlers/dependencies.md
@@ -84,7 +84,7 @@ A resolver's parameters resolve exactly like a tool's: another `Resolve(...)`, t
!!! 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](../advanced/authorization.md)**), not from a header anyone can set.
+ 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
@@ -120,7 +120,7 @@ That's the right default for a precondition: no answer, no order. When declining
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](../advanced/multi-round-trip.md)**). On
+ 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,
diff --git a/docs/tutorial/elicitation.md b/docs/handlers/elicitation.md
similarity index 97%
rename from docs/tutorial/elicitation.md
rename to docs/handlers/elicitation.md
index d9e016236..89ea317c6 100644
--- a/docs/tutorial/elicitation.md
+++ b/docs/handlers/elicitation.md
@@ -48,7 +48,7 @@ The client gets your message and, next to it, a JSON Schema generated from the m
}
```
-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 you already used for a tool's arguments in **[Tools](tools.md)**.
+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 you already used for a tool's arguments in **[Tools](../servers/tools.md)**.
!!! warning
An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields
@@ -128,7 +128,7 @@ Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(
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](../advanced/multi-round-trip.md)**.
+ instead; that flow is **[Multi-round-trip requests](multi-round-trip.md)**.
### Try it
@@ -167,6 +167,6 @@ Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit
* `result.action` is `"accept"`, `"decline"` or `"cancel"`; `result.data` exists only on accept.
* `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.
* 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](../advanced/multi-round-trip.md)**.
+* 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](../advanced/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)**.
diff --git a/docs/handlers/index.md b/docs/handlers/index.md
index 911f42c3c..cb1ef5535 100644
--- a/docs/handlers/index.md
+++ b/docs/handlers/index.md
@@ -5,23 +5,24 @@ everything it can do while it runs, is here.
What it can read:
-* **[The Context](../tutorial/context.md)** — the one extra parameter any handler can
- ask for: the live request, its headers, its session, and most of the verbs below.
-* **[Dependencies](../tutorial/dependencies.md)** — parameters the model never sees,
+* **[The Context](context.md)** — 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)** — parameters the model never sees,
filled in by your own functions with `Resolve`.
-* **[Lifespan](../tutorial/lifespan.md)** — state your server builds once at startup,
+* **[Lifespan](lifespan.md)** — 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 — **[Elicitation](../tutorial/elicitation.md)**, and
- **[Multi-round-trip requests](../advanced/multi-round-trip.md)**, the 2026-07-28
+* Ask the user for more input — **[Elicitation](elicitation.md)**, and
+ **[Multi-round-trip requests](multi-round-trip.md)**, the 2026-07-28
pattern that carries it.
-* Report **[Progress](../tutorial/progress.md)** on something slow.
+* Report **[Progress](progress.md)** on something slow.
* Write logs — to standard error, for whoever operates the server — with
- **[Logging](../tutorial/logging.md)**.
+ **[Logging](logging.md)**.
* Tell subscribed clients that something changed —
- **[Subscriptions](../advanced/subscriptions.md)**.
+ **[Subscriptions](subscriptions.md)**.
If you haven't registered a handler yet, start with
-**[Tools](../tutorial/tools.md)** — every page here assumes you have one.
+**[Tools](../servers/tools.md)** — every page here assumes you have one.
diff --git a/docs/tutorial/lifespan.md b/docs/handlers/lifespan.md
similarity index 100%
rename from docs/tutorial/lifespan.md
rename to docs/handlers/lifespan.md
diff --git a/docs/tutorial/logging.md b/docs/handlers/logging.md
similarity index 94%
rename from docs/tutorial/logging.md
rename to docs/handlers/logging.md
index 23519c241..5479fa64c 100644
--- a/docs/tutorial/logging.md
+++ b/docs/handlers/logging.md
@@ -2,7 +2,7 @@
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](../advanced/deprecated.md)**.
+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.
@@ -65,7 +65,7 @@ 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](../advanced/opentelemetry.md)**.
+ message with OpenTelemetry out of the box. See **[OpenTelemetry](../run/opentelemetry.md)**.
## Recap
@@ -75,4 +75,4 @@ went to standard error: the terminal, not the wire.
* 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.
-Next: telling connected clients that something on your server changed — the tool list, a resource — with **[Subscriptions](../advanced/subscriptions.md)**.
+Next: telling connected clients that something on your server changed — the tool list, a resource — with **[Subscriptions](subscriptions.md)**.
diff --git a/docs/advanced/multi-round-trip.md b/docs/handlers/multi-round-trip.md
similarity index 94%
rename from docs/advanced/multi-round-trip.md
rename to docs/handlers/multi-round-trip.md
index 0bd5b1e00..d5451e231 100644
--- a/docs/advanced/multi-round-trip.md
+++ b/docs/handlers/multi-round-trip.md
@@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t
## The server side
-On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user and the SDK returns the `InputRequiredResult` for you - that form is the **[Dependencies](../tutorial/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:
+On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user 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"
@@ -29,7 +29,7 @@ On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks t
* 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](low-level-server.md)**. This page only adds the second return type.
+Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **[The low-level Server](../advanced/low-level-server.md)**. This page only adds the second return type.
## Beyond tools
@@ -155,7 +155,7 @@ A `request_state` you set yourself (returning `InputRequiredResult` from a tool,
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](low-level-server.md#the-other-handlers)**.
+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
@@ -171,7 +171,7 @@ The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is
**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](../tutorial/elicitation.md)**.
+ high-level server half is in **[Elicitation](elicitation.md)**.
## Recap
@@ -179,8 +179,8 @@ The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is
* `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](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form.
+* 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)**.
+This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](../deprecated.md)**.
diff --git a/docs/tutorial/progress.md b/docs/handlers/progress.md
similarity index 98%
rename from docs/tutorial/progress.md
rename to docs/handlers/progress.md
index d553de473..26e3c453e 100644
--- a/docs/tutorial/progress.md
+++ b/docs/handlers/progress.md
@@ -51,7 +51,7 @@ 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](testing.md)**
+ `Client(mcp)` connects straight to the server object, in memory, the same client the **[Testing](../get-started/testing.md)**
chapter 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
diff --git a/docs/advanced/subscriptions.md b/docs/handlers/subscriptions.md
similarity index 100%
rename from docs/advanced/subscriptions.md
rename to docs/handlers/subscriptions.md
diff --git a/docs/index.md b/docs/index.md
index 837474731..26687d470 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -36,7 +36,7 @@ The `[cli]` extra gives you the `mcp` command; you'll want it for development.
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 beta before you copy the line
- above. See [Installation](installation.md) for the details.
+ above. See [Installation](get-started/installation.md) for the details.
## Example
@@ -89,7 +89,7 @@ You wrote two Python functions with type hints and a docstring. The SDK does the
## Where to go next
-* **[Get started](tutorial/index.md)** takes you from install to a working, tested server.
+* **[Get started](get-started/index.md)** takes you from install to a working, tested server.
* 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.
* Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format:
diff --git a/docs/migration.md b/docs/migration.md
index 6cf4913f2..bb5eb3d69 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -27,7 +27,7 @@ Like `call_tool()` above, `MCPServer.get_prompt()` now returns
`Iterable[ReadResourceContents] | InputRequiredResult`: at 2026-07-28 an
`@mcp.prompt()` function or an `@mcp.resource()` template function may answer
with an `InputRequiredResult` to request client input first (see
-[Multi-round-trip requests](advanced/multi-round-trip.md)). If you call these
+[Multi-round-trip requests](handlers/multi-round-trip.md)). If you call these
methods directly, narrow with `isinstance` (or
`assert not isinstance(result, InputRequiredResult)` when your prompt and
resource functions never return one). `Prompt.render()` and
@@ -439,7 +439,7 @@ Base64-sentinel decoding is strict everywhere it applies, including the `Mcp-Nam
### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549))
-On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. With the default configuration, servers that send no hints, including every pre-2026 server, see identical call-for-call behavior, because hint-less results are not cached (a `CacheConfig.default_ttl_ms` above zero caches them too). Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](advanced/caching.md).
+On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. With the default configuration, servers that send no hints, including every pre-2026 server, see identical call-for-call behavior, because hint-less results are not cached (a `CacheConfig.default_ttl_ms` above zero caches them too). Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](client/caching.md).
### Server extensions API ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133))
@@ -785,7 +785,7 @@ Context injection for static resources is not supported — use a
template with at least one variable or access context through other
means.
-See [URI templates](advanced/uri-templates.md) for the full template syntax,
+See [URI templates](servers/uri-templates.md) for the full template syntax,
security configuration, and filesystem safety utilities.
### Registering lowlevel handlers from `MCPServer`
@@ -1665,7 +1665,7 @@ The implementation is responsible for validating the assertion per RFC 7523 §3
### 2025-11-25 and 2026-07-28 protocol fields modeled
-`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. Servers set per-method values with `cache_hints={method: CacheHint(...)}` on the `Server`/`MCPServer` constructor — see [Caching hints](advanced/caching.md).
+`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. Servers set per-method values with `cache_hints={method: CacheHint(...)}` on the `Server`/`MCPServer` constructor — see [Caching hints](client/caching.md).
### `streamable_http_app()` available on lowlevel Server
@@ -1693,7 +1693,7 @@ The lowlevel `Server` also now exposes a `session_manager` property to access th
### `ElicitationResult` is now a subscriptable generic alias
-`ElicitationResult` is now a `TypeAliasType` instead of a plain union, so `ElicitationResult[Confirm]` works as an annotation (resolver dependency injection consumes it that way - see [Dependencies](tutorial/dependencies.md)). The members are unchanged: `AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation`.
+`ElicitationResult` is now a `TypeAliasType` instead of a plain union, so `ElicitationResult[Confirm]` works as an annotation (resolver dependency injection consumes it that way - see [Dependencies](handlers/dependencies.md)). The members are unchanged: `AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation`.
The one behavioral change: a runtime `isinstance(result, ElicitationResult)` now raises `TypeError`. Check against the member classes directly instead:
diff --git a/docs/client/protocol-versions.md b/docs/protocol-versions.md
similarity index 97%
rename from docs/client/protocol-versions.md
rename to docs/protocol-versions.md
index 43624549c..80bd5acf2 100644
--- a/docs/client/protocol-versions.md
+++ b/docs/protocol-versions.md
@@ -48,9 +48,9 @@ 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](../advanced/multi-round-trip.md)**).
+At 2026-07-28 it is gone. The server *returns* its questions and you retry the call with the answers (**[Multi-round-trip requests](handlers/multi-round-trip.md)**).
-`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](callbacks.md)** goes through each.
+`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](client/callbacks.md)** goes through each.
## Pinning a version
@@ -124,4 +124,4 @@ The second connection made **zero** negotiation round trips and still knows exac
* 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](../advanced/multi-round-trip.md)**.
+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](handlers/multi-round-trip.md)**.
diff --git a/docs/advanced/authorization.md b/docs/run/authorization.md
similarity index 97%
rename from docs/advanced/authorization.md
rename to docs/run/authorization.md
index 9b5a32a4e..053bb755a 100644
--- a/docs/advanced/authorization.md
+++ b/docs/run/authorization.md
@@ -36,7 +36,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl
## 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](../run/index.md)** has the rest. The app now has two routes:
+Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: `mcp.run(transport="streamable-http")` puts it on `http://127.0.0.1:8000/mcp`, and **[Running your server](index.md)** has the rest. The app now has two routes:
```text
/mcp
@@ -109,7 +109,7 @@ To watch all three parties move, run `examples/servers/simple-auth/` from the SD
server inside your MCP server. It predates the AS/RS separation that the MCP authorization spec
is built around. New servers should not reach for it.
-An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is **[Identity assertion](identity-assertion.md)**.
+An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is **[Identity assertion](../client/identity-assertion.md)**.
## Recap
@@ -120,4 +120,4 @@ An authorization server can also accept an enterprise identity provider's signed
* `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](oauth-clients.md)**.
+The other side of the handshake, a client that discovers your authorization server and fetches the token for you, is **[OAuth clients](../client/oauth-clients.md)**.
diff --git a/docs/run/index.md b/docs/run/index.md
index 0c38dcf74..322c7a0a8 100644
--- a/docs/run/index.md
+++ b/docs/run/index.md
@@ -39,7 +39,7 @@ python server.py
Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first.
-That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **[Logging](../tutorial/logging.md)**.
+That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **[Logging](../handlers/logging.md)**.
### Try it
diff --git a/docs/advanced/opentelemetry.md b/docs/run/opentelemetry.md
similarity index 97%
rename from docs/advanced/opentelemetry.md
rename to docs/run/opentelemetry.md
index 1193a6e8b..c36f5ceaa 100644
--- a/docs/advanced/opentelemetry.md
+++ b/docs/run/opentelemetry.md
@@ -90,7 +90,7 @@ mcp._lowlevel_server.middleware[:] = [
!!! warning
That import has a leading underscore, and that is on purpose. The class is provisional, the
- same way [`Server.middleware`](middleware.md) is provisional, so the import path is something
+ same way [`Server.middleware`](../advanced/middleware.md) is provisional, so the import path is something
you should expect to change. You almost never need this: with no exporter installed the spans
are free, so the usual answer is to leave them on and not install an exporter.
diff --git a/docs/tutorial/completions.md b/docs/servers/completions.md
similarity index 96%
rename from docs/tutorial/completions.md
rename to docs/servers/completions.md
index afd3c4bef..98665ef3b 100644
--- a/docs/tutorial/completions.md
+++ b/docs/servers/completions.md
@@ -39,7 +39,7 @@ Add **one** function decorated with `@mcp.completion()`:
### Try it
-Drive it with the in-memory `Client`, the same one you use in **[Testing](testing.md)**. Call
+Drive it with the in-memory `Client`, the same one you use in **[Testing](../get-started/testing.md)**. Call
`client.complete()` with `ref=PromptReference(name="review_code")` and
`argument={"name": "language", "value": "py"}`:
@@ -122,4 +122,4 @@ Drop `context_arguments=` and the same call returns `[]`. The handler can't know
* `context.arguments` holds the already-resolved values; the client supplies them as `context_arguments=`.
* The `completions` capability appears the moment you register the handler. Without it, the request is `Method not found`.
-Suggestions help while the user is still *filling in* a prompt or template; to ask them a question in the *middle* of a tool call, you want **[Elicitation](elicitation.md)**. Next: everything a tool can return besides text, in **[Images, audio & icons](media.md)**.
+Suggestions help while the user is still *filling in* a prompt or template; to ask them a question in the *middle* of a tool call, you want **[Elicitation](../handlers/elicitation.md)**. Next: everything a tool can return besides text, in **[Images, audio & icons](media.md)**.
diff --git a/docs/tutorial/handling-errors.md b/docs/servers/handling-errors.md
similarity index 98%
rename from docs/tutorial/handling-errors.md
rename to docs/servers/handling-errors.md
index 0b2aca23b..cf30d4fa0 100644
--- a/docs/tutorial/handling-errors.md
+++ b/docs/servers/handling-errors.md
@@ -118,7 +118,7 @@ It means a whole class of `raise` statements you don't write: don't re-validate
Everything on this page is what a **client** sees, and the in-memory `Client` you'll write
tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error
back into a traceback: by the time that flag could act, your exception is already the
- `is_error=True` result. Assert on the result. **[Testing](testing.md)** covers the pattern.
+ `is_error=True` result. Assert on the result. **[Testing](../get-started/testing.md)** covers the pattern.
## Recap
diff --git a/docs/servers/index.md b/docs/servers/index.md
index f2769ac74..4989fe2d9 100644
--- a/docs/servers/index.md
+++ b/docs/servers/index.md
@@ -3,27 +3,27 @@
An `MCPServer` exposes three primitives to a connected client. They differ by who
decides to use them:
-* A **[tool](../tutorial/tools.md)** is an action the *model* picks and calls. This is
+* A **[tool](tools.md)** is an action the *model* picks and calls. This is
the page most people want first, and
- **[Structured Output](../tutorial/structured-output.md)** is its reference companion:
+ **[Structured Output](structured-output.md)** is its reference companion:
everything about the shape of what a tool returns.
-* A **[resource](../tutorial/resources.md)** is read-only data the *application*
- chooses to read. **[URI templates](../advanced/uri-templates.md)** is its reference
+* A **[resource](resources.md)** is read-only data the *application*
+ chooses to read. **[URI templates](uri-templates.md)** is its reference
companion: the full addressing syntax and the path-safety rules.
-* A **[prompt](../tutorial/prompts.md)** is a message template a *person* invokes by
+* A **[prompt](prompts.md)** is a message template a *person* invokes by
name, from a menu or a slash command.
Around the three primitives, the rest of what a server declares:
-* **[Completions](../tutorial/completions.md)** — server-side autocomplete for prompt
+* **[Completions](completions.md)** — server-side autocomplete for prompt
and resource-template arguments.
-* **[Images, audio & icons](../tutorial/media.md)** — everything a tool can
+* **[Images, audio & icons](media.md)** — everything a tool can
return besides text, and the icons a client shows next to your server.
-* **[Handling errors](../tutorial/handling-errors.md)** — the difference between an
+* **[Handling errors](handling-errors.md)** — the difference between an
error the model can recover from and one it must never see.
Every page here stands on its own; jump straight to the one you need. If you haven't
-built a server yet, start with **[First steps](../tutorial/first-steps.md)** instead.
+built a server yet, start with **[First steps](../get-started/first-steps.md)** instead.
What happens *inside* the functions you register — the `Context`, dependency injection,
asking the user for more input mid-call — is the next section,
diff --git a/docs/tutorial/media.md b/docs/servers/media.md
similarity index 100%
rename from docs/tutorial/media.md
rename to docs/servers/media.md
diff --git a/docs/tutorial/prompts.md b/docs/servers/prompts.md
similarity index 100%
rename from docs/tutorial/prompts.md
rename to docs/servers/prompts.md
diff --git a/docs/tutorial/resources.md b/docs/servers/resources.md
similarity index 97%
rename from docs/tutorial/resources.md
rename to docs/servers/resources.md
index badd167f0..a6437ee83 100644
--- a/docs/tutorial/resources.md
+++ b/docs/servers/resources.md
@@ -92,9 +92,9 @@ Notice the `uri` in the result. It is the **concrete** URI the client asked for,
A mismatch can only ever be a bug, so the SDK makes it impossible to start the server with one.
-The placeholder syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` for multi-segment values, `{?q,lang}` for optional query parameters, and more. The SDK also applies path-safety checks to extracted values by default. See **[URI templates and path safety](../advanced/uri-templates.md)** for the full reference.
+The placeholder syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` for multi-segment values, `{?q,lang}` for optional query parameters, and more. The SDK also applies path-safety checks to extracted values by default. See **[URI templates and path safety](uri-templates.md)** for the full reference.
-`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **[The Context](context.md)** chapter covers what it gives you.
+`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **[The Context](../handlers/context.md)** chapter covers what it gives you.
## What you return
diff --git a/docs/tutorial/structured-output.md b/docs/servers/structured-output.md
similarity index 100%
rename from docs/tutorial/structured-output.md
rename to docs/servers/structured-output.md
diff --git a/docs/tutorial/tools.md b/docs/servers/tools.md
similarity index 100%
rename from docs/tutorial/tools.md
rename to docs/servers/tools.md
diff --git a/docs/advanced/uri-templates.md b/docs/servers/uri-templates.md
similarity index 96%
rename from docs/advanced/uri-templates.md
rename to docs/servers/uri-templates.md
index 51208d725..017c70376 100644
--- a/docs/advanced/uri-templates.md
+++ b/docs/servers/uri-templates.md
@@ -1,10 +1,10 @@
# URI templates and path safety
This is the reference for the URI-template syntax that
-[`@mcp.resource`](../tutorial/resources.md) accepts, and for the
+[`@mcp.resource`](resources.md) accepts, and for the
path-safety policy the SDK applies to extracted values. For an
introduction to what resources are and when to use them, start with
-**[Resources](../tutorial/resources.md)**; this page assumes you're already comfortable declaring a
+**[Resources](resources.md)**; this page assumes you're already comfortable declaring a
resource and want the full operator set, the security knobs, or the
low-level wiring.
@@ -17,7 +17,7 @@ details (message formats, lifecycle, pagination) see the
## The full operator set
-**[Resources](../tutorial/resources.md)** showed one placeholder, `{user_id}`. There are four more
+**[Resources](resources.md)** showed one placeholder, `{user_id}`. There are four more
operator forms; here they are on one server so you can see them next to
each other:
@@ -201,13 +201,13 @@ These checks are a heuristic pre-filter; for filesystem access,
!!! tip
If your handler can't fulfil the request (the file doesn't exist,
the id is unknown), raise an exception. The SDK turns it into an
- error response. See **[Handling errors](../tutorial/handling-errors.md)** for the difference between a
+ error response. See **[Handling errors](handling-errors.md)** for the difference between a
protocol error and a tool error.
## Resources on the low-level Server
If you're building on the low-level `Server` (see **[The low-level
-Server](low-level-server.md)**), you register handlers for the `resources/list` and
+Server](../advanced/low-level-server.md)**), you register handlers for the `resources/list` and
`resources/read` protocol methods directly. There's no decorator; you
return the protocol types yourself.
diff --git a/examples/stories/subscriptions/README.md b/examples/stories/subscriptions/README.md
index 22b947ba3..c7f1a4436 100644
--- a/examples/stories/subscriptions/README.md
+++ b/examples/stories/subscriptions/README.md
@@ -56,5 +56,5 @@ uv run python -m stories.subscriptions.client --http --server server_lowlevel
## See also
`streaming/` (request-scoped notifications), `events/` (the events extension
-on top of this channel, deferred), and `docs/advanced/subscriptions.md` (the
+on top of this channel, deferred), and `docs/handlers/subscriptions.md` (the
narrative version).
diff --git a/mkdocs.yml b/mkdocs.yml
index a3f3b8281..d7ed35487 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -13,45 +13,45 @@ site_url: https://py.sdk.modelcontextprotocol.io/v2/
nav:
- MCP Python SDK: index.md
- Get started:
- - tutorial/index.md
- - Installation: installation.md
- - First steps: tutorial/first-steps.md
- - Testing: tutorial/testing.md
+ - get-started/index.md
+ - Installation: get-started/installation.md
+ - First steps: get-started/first-steps.md
+ - Testing: get-started/testing.md
- Servers:
- servers/index.md
- - Tools: tutorial/tools.md
- - Structured Output: tutorial/structured-output.md
- - Resources: tutorial/resources.md
- - URI templates: advanced/uri-templates.md
- - Prompts: tutorial/prompts.md
- - Completions: tutorial/completions.md
- - "Images, audio & icons": tutorial/media.md
- - Handling errors: tutorial/handling-errors.md
+ - Tools: servers/tools.md
+ - Structured Output: servers/structured-output.md
+ - Resources: servers/resources.md
+ - URI templates: servers/uri-templates.md
+ - Prompts: servers/prompts.md
+ - Completions: servers/completions.md
+ - "Images, audio & icons": servers/media.md
+ - Handling errors: servers/handling-errors.md
- Inside your handler:
- handlers/index.md
- - The Context: tutorial/context.md
- - Dependencies: tutorial/dependencies.md
- - Lifespan: tutorial/lifespan.md
- - Elicitation: tutorial/elicitation.md
- - Multi-round-trip requests: advanced/multi-round-trip.md
- - Progress: tutorial/progress.md
- - Logging: tutorial/logging.md
- - Subscriptions: advanced/subscriptions.md
+ - The Context: handlers/context.md
+ - Dependencies: handlers/dependencies.md
+ - Lifespan: handlers/lifespan.md
+ - Elicitation: handlers/elicitation.md
+ - Multi-round-trip requests: handlers/multi-round-trip.md
+ - Progress: handlers/progress.md
+ - Logging: handlers/logging.md
+ - Subscriptions: handlers/subscriptions.md
- Running your server:
- run/index.md
- Add to an existing app: run/asgi.md
- - Authorization: advanced/authorization.md
- - OpenTelemetry: advanced/opentelemetry.md
+ - Authorization: run/authorization.md
+ - OpenTelemetry: run/opentelemetry.md
- Clients:
- client/index.md
- Callbacks: client/callbacks.md
- Transports: client/transports.md
- - OAuth: advanced/oauth-clients.md
- - Identity assertion: advanced/identity-assertion.md
- - Multiple servers: advanced/session-groups.md
- - Caching: advanced/caching.md
- - Protocol versions: client/protocol-versions.md
- - Deprecated features: advanced/deprecated.md
+ - OAuth: client/oauth-clients.md
+ - Identity assertion: client/identity-assertion.md
+ - Multiple servers: client/session-groups.md
+ - Caching: client/caching.md
+ - Protocol versions: protocol-versions.md
+ - Deprecated features: deprecated.md
- Advanced:
- advanced/index.md
- The low-level Server: advanced/low-level-server.md
@@ -167,6 +167,36 @@ plugins:
- docs/hooks/gen_ref_pages.py
- literate-nav:
nav_file: SUMMARY.md
+ - redirects:
+ redirect_maps:
+ advanced/authorization.md: run/authorization.md
+ advanced/caching.md: client/caching.md
+ advanced/deprecated.md: deprecated.md
+ advanced/identity-assertion.md: client/identity-assertion.md
+ advanced/multi-round-trip.md: handlers/multi-round-trip.md
+ advanced/oauth-clients.md: client/oauth-clients.md
+ advanced/opentelemetry.md: run/opentelemetry.md
+ advanced/session-groups.md: client/session-groups.md
+ advanced/subscriptions.md: handlers/subscriptions.md
+ advanced/uri-templates.md: servers/uri-templates.md
+ client/protocol-versions.md: protocol-versions.md
+ installation.md: get-started/installation.md
+ tutorial/completions.md: servers/completions.md
+ tutorial/context.md: handlers/context.md
+ tutorial/dependencies.md: handlers/dependencies.md
+ tutorial/elicitation.md: handlers/elicitation.md
+ tutorial/first-steps.md: get-started/first-steps.md
+ tutorial/handling-errors.md: servers/handling-errors.md
+ tutorial/index.md: get-started/index.md
+ tutorial/lifespan.md: handlers/lifespan.md
+ tutorial/logging.md: handlers/logging.md
+ tutorial/media.md: servers/media.md
+ tutorial/progress.md: handlers/progress.md
+ tutorial/prompts.md: servers/prompts.md
+ tutorial/resources.md: servers/resources.md
+ tutorial/structured-output.md: servers/structured-output.md
+ tutorial/testing.md: get-started/testing.md
+ tutorial/tools.md: servers/tools.md
- mkdocstrings:
handlers:
python:
diff --git a/tests/client/test_client_caching.py b/tests/client/test_client_caching.py
index 708d83db4..1feb34038 100644
--- a/tests/client/test_client_caching.py
+++ b/tests/client/test_client_caching.py
@@ -981,7 +981,7 @@ async def on_message(message: IncomingMessage) -> None:
async def test_the_modern_in_process_path_drops_the_eviction_notification() -> None:
"""Pins the documented gap: the default in-process path (DirectDispatcher) drops
standalone notifications, so the warm entry survives. If this starts failing the
- path gained delivery: flip the `docs/advanced/caching.md` caveat and the legacy-mode tests."""
+ path gained delivery: flip the `docs/client/caching.md` caveat and the legacy-mode tests."""
fetches: list[str | None] = []
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
diff --git a/tests/docs_src/test_authorization.py b/tests/docs_src/test_authorization.py
index 4c7554ed7..cde0cea5f 100644
--- a/tests/docs_src/test_authorization.py
+++ b/tests/docs_src/test_authorization.py
@@ -1,4 +1,4 @@
-"""`docs/advanced/authorization.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/run/authorization.md`: every claim the page makes, proved against the real SDK."""
import httpx
import pytest
diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py
index 58014879c..2fafde0a1 100644
--- a/tests/docs_src/test_caching.py
+++ b/tests/docs_src/test_caching.py
@@ -1,4 +1,4 @@
-"""`docs/advanced/caching.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/client/caching.md`: every claim the page makes, proved against the real SDK."""
from collections.abc import Mapping
from typing import Any, cast
diff --git a/tests/docs_src/test_completions.py b/tests/docs_src/test_completions.py
index b1f5c1816..43b1262d5 100644
--- a/tests/docs_src/test_completions.py
+++ b/tests/docs_src/test_completions.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/completions.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/servers/completions.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
diff --git a/tests/docs_src/test_context.py b/tests/docs_src/test_context.py
index 2948b10f5..617d113b2 100644
--- a/tests/docs_src/test_context.py
+++ b/tests/docs_src/test_context.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/context.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/handlers/context.md`: every claim the page makes, proved against the real SDK."""
import re
diff --git a/tests/docs_src/test_dependencies.py b/tests/docs_src/test_dependencies.py
index 06d893585..6dba9277e 100644
--- a/tests/docs_src/test_dependencies.py
+++ b/tests/docs_src/test_dependencies.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/dependencies.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/handlers/dependencies.md`: every claim the page makes, proved against the real SDK."""
from typing import Literal
diff --git a/tests/docs_src/test_deprecated.py b/tests/docs_src/test_deprecated.py
index 892a8f362..090ca6164 100644
--- a/tests/docs_src/test_deprecated.py
+++ b/tests/docs_src/test_deprecated.py
@@ -1,4 +1,4 @@
-"""`docs/advanced/deprecated.md`: the page's behavioural claims, executed against the live SDK.
+"""`docs/deprecated.md`: the page's behavioural claims, executed against the live SDK.
This chapter has no `docs_src/` example by design: it is the one page allowed to name
the deprecated methods, and a runnable example would teach exactly what the page tells
diff --git a/tests/docs_src/test_elicitation.py b/tests/docs_src/test_elicitation.py
index a28f1087f..17933816b 100644
--- a/tests/docs_src/test_elicitation.py
+++ b/tests/docs_src/test_elicitation.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/elicitation.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/handlers/elicitation.md`: every claim the page makes, proved against the real SDK."""
from typing import Literal
diff --git a/tests/docs_src/test_first_steps.py b/tests/docs_src/test_first_steps.py
index 2b1674a47..11989850a 100644
--- a/tests/docs_src/test_first_steps.py
+++ b/tests/docs_src/test_first_steps.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/first-steps.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/get-started/first-steps.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
diff --git a/tests/docs_src/test_handling_errors.py b/tests/docs_src/test_handling_errors.py
index 1a76a7bb7..0c2629169 100644
--- a/tests/docs_src/test_handling_errors.py
+++ b/tests/docs_src/test_handling_errors.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/handling-errors.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/servers/handling-errors.md`: every claim the page makes, proved against the real SDK."""
import pytest
from mcp_types import INVALID_PARAMS, ErrorData, TextContent, TextResourceContents
diff --git a/tests/docs_src/test_identity_assertion.py b/tests/docs_src/test_identity_assertion.py
index afcfd8329..adfe23bad 100644
--- a/tests/docs_src/test_identity_assertion.py
+++ b/tests/docs_src/test_identity_assertion.py
@@ -1,4 +1,4 @@
-"""`docs/advanced/identity-assertion.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/client/identity-assertion.md`: every claim the page makes, proved against the real SDK."""
import inspect
from urllib.parse import parse_qsl
diff --git a/tests/docs_src/test_lifespan.py b/tests/docs_src/test_lifespan.py
index d78764fd6..ec6e98d7d 100644
--- a/tests/docs_src/test_lifespan.py
+++ b/tests/docs_src/test_lifespan.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/lifespan.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/handlers/lifespan.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
diff --git a/tests/docs_src/test_logging.py b/tests/docs_src/test_logging.py
index fa4b995c6..bed5c234b 100644
--- a/tests/docs_src/test_logging.py
+++ b/tests/docs_src/test_logging.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/logging.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/handlers/logging.md`: every claim the page makes, proved against the real SDK."""
import logging
diff --git a/tests/docs_src/test_media.py b/tests/docs_src/test_media.py
index 96ea42a0b..2ef5eb7e5 100644
--- a/tests/docs_src/test_media.py
+++ b/tests/docs_src/test_media.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/media.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/servers/media.md`: every claim the page makes, proved against the real SDK."""
import base64
diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py
index cf7842b0a..50a9e53d9 100644
--- a/tests/docs_src/test_mrtr.py
+++ b/tests/docs_src/test_mrtr.py
@@ -1,4 +1,4 @@
-"""`docs/advanced/multi-round-trip.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/handlers/multi-round-trip.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
diff --git a/tests/docs_src/test_oauth_clients.py b/tests/docs_src/test_oauth_clients.py
index a85eab388..abd45e016 100644
--- a/tests/docs_src/test_oauth_clients.py
+++ b/tests/docs_src/test_oauth_clients.py
@@ -1,4 +1,4 @@
-"""`docs/advanced/oauth-clients.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/client/oauth-clients.md`: every claim the page makes, proved against the real SDK."""
import inspect
diff --git a/tests/docs_src/test_opentelemetry.py b/tests/docs_src/test_opentelemetry.py
index 00f3af8aa..17b153c26 100644
--- a/tests/docs_src/test_opentelemetry.py
+++ b/tests/docs_src/test_opentelemetry.py
@@ -1,4 +1,4 @@
-"""`docs/advanced/opentelemetry.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/run/opentelemetry.md`: every claim the page makes, proved against the real SDK."""
import pytest
from logfire.testing import CaptureLogfire
diff --git a/tests/docs_src/test_progress.py b/tests/docs_src/test_progress.py
index 45cc4df8e..a05577fba 100644
--- a/tests/docs_src/test_progress.py
+++ b/tests/docs_src/test_progress.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/progress.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/handlers/progress.md`: every claim the page makes, proved against the real SDK."""
import inspect
diff --git a/tests/docs_src/test_prompts.py b/tests/docs_src/test_prompts.py
index 1cbab3af0..3b0ad571a 100644
--- a/tests/docs_src/test_prompts.py
+++ b/tests/docs_src/test_prompts.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/prompts.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/servers/prompts.md`: every claim the page makes, proved against the real SDK."""
import traceback
diff --git a/tests/docs_src/test_protocol_versions.py b/tests/docs_src/test_protocol_versions.py
index f8e5b19f1..73366a984 100644
--- a/tests/docs_src/test_protocol_versions.py
+++ b/tests/docs_src/test_protocol_versions.py
@@ -1,4 +1,4 @@
-"""`docs/client/protocol-versions.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/protocol-versions.md`: every claim the page makes, proved against the real SDK."""
import re
diff --git a/tests/docs_src/test_resources.py b/tests/docs_src/test_resources.py
index 85e827833..3fbde00fd 100644
--- a/tests/docs_src/test_resources.py
+++ b/tests/docs_src/test_resources.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/resources.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/servers/resources.md`: every claim the page makes, proved against the real SDK."""
import base64
diff --git a/tests/docs_src/test_session_groups.py b/tests/docs_src/test_session_groups.py
index e6fee8ce9..79721c613 100644
--- a/tests/docs_src/test_session_groups.py
+++ b/tests/docs_src/test_session_groups.py
@@ -1,4 +1,4 @@
-"""`docs/advanced/session-groups.md`: every claim the page makes, proved against the real SDK.
+"""`docs/client/session-groups.md`: every claim the page makes, proved against the real SDK.
`connect_to_server` opens a real transport (a subprocess or a socket), so these tests drive the
exact same aggregation path through `connect_with_session` with in-memory sessions instead.
diff --git a/tests/docs_src/test_structured_output.py b/tests/docs_src/test_structured_output.py
index 795b0ccf1..c0b900d2d 100644
--- a/tests/docs_src/test_structured_output.py
+++ b/tests/docs_src/test_structured_output.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/structured-output.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/servers/structured-output.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
diff --git a/tests/docs_src/test_subscriptions.py b/tests/docs_src/test_subscriptions.py
index cdfe1d935..b664afe98 100644
--- a/tests/docs_src/test_subscriptions.py
+++ b/tests/docs_src/test_subscriptions.py
@@ -1,4 +1,4 @@
-"""`docs/advanced/subscriptions.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/handlers/subscriptions.md`: every claim the page makes, proved against the real SDK."""
from typing import Any
diff --git a/tests/docs_src/test_testing.py b/tests/docs_src/test_testing.py
index 035f72312..5ab73e2e9 100644
--- a/tests/docs_src/test_testing.py
+++ b/tests/docs_src/test_testing.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/testing.md`: the page's own test, run for real.
+"""`docs/get-started/testing.md`: the page's own test, run for real.
The page shows this test against a `server.py` next to it; here the import path
is the only difference.
diff --git a/tests/docs_src/test_tools.py b/tests/docs_src/test_tools.py
index 08e2a5ca6..c4051794f 100644
--- a/tests/docs_src/test_tools.py
+++ b/tests/docs_src/test_tools.py
@@ -1,4 +1,4 @@
-"""`docs/tutorial/tools.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/servers/tools.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot
diff --git a/tests/docs_src/test_uri_templates.py b/tests/docs_src/test_uri_templates.py
index b90e099c1..4b2b6edaf 100644
--- a/tests/docs_src/test_uri_templates.py
+++ b/tests/docs_src/test_uri_templates.py
@@ -1,4 +1,4 @@
-"""`docs/advanced/uri-templates.md`: every claim the page makes, proved against the real SDK."""
+"""`docs/servers/uri-templates.md`: every claim the page makes, proved against the real SDK."""
from pathlib import Path
diff --git a/tests/server/test_caching.py b/tests/server/test_caching.py
index abfcfba97..0a6adc2aa 100644
--- a/tests/server/test_caching.py
+++ b/tests/server/test_caching.py
@@ -168,7 +168,7 @@ async def test_every_page_of_a_paginated_list_carries_the_configured_scope() ->
"""Spec-mandated: the same `cacheScope` MUST apply to all pages of one list.
The map is keyed by method, not cursor, so a handler that leaves scope unset
gets the same scope on every page. (A handler that overrides the scope owns
- that consistency itself - see `docs/advanced/caching.md`.)"""
+ that consistency itself - see `docs/client/caching.md`.)"""
names = [f"r-{n}" for n in range(4)]
async def list_resources(
diff --git a/tests/test_examples.py b/tests/test_examples.py
index dba562408..9a329c47a 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -102,8 +102,9 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
find_examples(
"README.md",
"docs/index.md",
- "docs/installation.md",
- "docs/tutorial",
+ "docs/protocol-versions.md",
+ "docs/deprecated.md",
+ "docs/get-started",
"docs/servers",
"docs/handlers",
"docs/run",
From 171971e8f6ae645e0658a2756f93d55e4f5c3a5e Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 16:12:13 +0000
Subject: [PATCH 05/15] docs: reword every page to stand on its own
The pages were written for a linear read-through, so many refer to the
reader's history on ANOTHER page -- "In Tools you returned a str and the
result came back twice", "the input schema you met in Tools", "the same
one you use in Testing", "So far every request has gone one way", "you
already know". Most people arrive at reference docs from a search engine
and read one page; for them those sentences are false and read as steps
in a walkthrough they are not on.
An audit of all 42 pages found 24 such sentences on 16 pages (26 pages
were already clean). Each is rewritten to carry the SAME facts and,
almost always, the SAME cross-link, with only the false claim about the
reader's history removed:
In [Tools] you returned a str and the result came back twice ...
-> A tool that returns a plain str produces the result twice ...
the TextContent you met in [Tools]
-> the TextContent a plain str result becomes ([Tools])
You saw this in [Tools] with Field(le=50).
-> [Tools] shows the same rejection with a Field(le=50) constraint.
Cross-REFERENCES are deliberately untouched: routing the reader
elsewhere for MORE ("the full addressing syntax is on [URI templates]")
is what good reference docs do. Only a sentence that DEPENDS on another
page to make sense is the bug. Every rewritten factual claim was
re-verified against the SDK source, not against the docs.
Also:
- The word "chapter" becomes "page" everywhere (27 sites): a book has
chapters, a reference has pages.
- The landing page's "Where to go next" gains the two audience routes it
was missing: someone building a CLIENT, and someone adding MCP to an
app they already run. The README routes both; the docs did not.
- migration.md's Tasks note is corrected. It said Tasks "are expected to
return as a separate MCP extension in a future release"; the
2026-07-28 revision reintroduces them as SEP-2663
(io.modelcontextprotocol/tasks), redesigned around polling. This SDK
does not implement the extension yet, and the note now says so.
---
docs/advanced/low-level-server.md | 8 ++++----
docs/advanced/pagination.md | 2 +-
docs/client/callbacks.md | 4 ++--
docs/client/identity-assertion.md | 8 ++++----
docs/client/index.md | 10 +++++-----
docs/client/oauth-clients.md | 8 ++++----
docs/client/transports.md | 2 +-
docs/get-started/first-steps.md | 8 ++++----
docs/get-started/testing.md | 2 +-
docs/handlers/context.md | 4 ++--
docs/handlers/elicitation.md | 6 +++---
docs/handlers/progress.md | 4 ++--
docs/index.md | 2 ++
docs/migration.md | 2 +-
docs/protocol-versions.md | 2 +-
docs/servers/completions.md | 2 +-
docs/servers/handling-errors.md | 4 ++--
docs/servers/media.md | 2 +-
docs/servers/prompts.md | 4 ++--
docs/servers/resources.md | 2 +-
docs/servers/structured-output.md | 6 +++---
docs/servers/tools.md | 2 +-
docs/servers/uri-templates.md | 4 ++--
23 files changed, 50 insertions(+), 48 deletions(-)
diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md
index 7ad391e3d..26df8f612 100644
--- a/docs/advanced/low-level-server.md
+++ b/docs/advanced/low-level-server.md
@@ -12,7 +12,7 @@ For everything else, stay on `MCPServer`.
## The same tool, by hand
-This is `search_books` from **[Tools](../servers/tools.md)** (the nine-line `@mcp.tool()` file) with the sugar removed:
+This is the `search_books` tool that **[Tools](../servers/tools.md)** writes in nine lines of `@mcp.tool()`, with the sugar removed:
```python title="server.py" hl_lines="23 27 33"
--8<-- "docs_src/lowlevel/tutorial001.py"
@@ -56,12 +56,12 @@ asyncio.run(main())
The same text the `@mcp.tool()` version produced. Two honest differences:
-* `result.structured_content` is `None`. The high-level server wrapped your `-> str` into `{"result": ...}`; here nobody builds what you didn't build.
+* `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build.
* `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there.
## Nothing is checked for you
-In **[Tools](../servers/tools.md)** you saw a bad argument get rejected before your function ran. That was `MCPServer` validating the call against the schema it generated.
+`MCPServer` rejects a bad argument before your function ever runs, validating the call against the schema it generated (**[Tools](../servers/tools.md)**).
`Server` does not do that. Your `input_schema` is *advertised* to the client; it is never *applied* to `params.arguments`.
@@ -179,7 +179,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other
## The other handlers
-Each of these is one idea you now have the vocabulary for; each has its own chapter.
+Each of these is one idea you now have the vocabulary for; each has its own page.
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**).
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md
index aac63f4c7..381f7fae0 100644
--- a/docs/advanced/pagination.md
+++ b/docs/advanced/pagination.md
@@ -48,7 +48,7 @@ Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resourc
Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages.
-This is the same loop **[The Client](../client/index.md)** chapter showed you, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once.
+This is the same loop **[The Client](../client/index.md)** shows for every `list_*` verb, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once.
## The three rules
diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md
index 809b18629..cc77d241f 100644
--- a/docs/client/callbacks.md
+++ b/docs/client/callbacks.md
@@ -1,6 +1,6 @@
# Client callbacks
-So far every request has gone one way: client to server.
+Nearly every request in MCP goes one way: client to server.
A server can also ask the **client** for things: to put a question to the user, to sample the user's model, to list the user's workspace folders. You answer those requests by passing **callbacks** to `Client(...)`.
@@ -15,7 +15,7 @@ Here is a server whose tool can't finish on its own:
* `ctx.elicit(...)` sends an `elicitation/create` request **to the client** and waits.
* The tool doesn't return until somebody (a person in a form, or your code) supplies a `name`.
-That is the server half, and the **[Elicitation](../handlers/elicitation.md)** chapter owns it. This chapter is the other end of the wire.
+That is the server half, and the **[Elicitation](../handlers/elicitation.md)** page owns it. This page is the other end of the wire.
## The elicitation callback
diff --git a/docs/client/identity-assertion.md b/docs/client/identity-assertion.md
index b5d03c1ea..908f08a14 100644
--- a/docs/client/identity-assertion.md
+++ b/docs/client/identity-assertion.md
@@ -1,14 +1,14 @@
# Identity assertion
-Every provider in **[OAuth clients](oauth-clients.md)** starts by asking the MCP server a question: *which authorization server do you trust?* It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one.
+An ordinary OAuth provider (**[OAuth clients](oauth-clients.md)**) starts by asking the MCP server a question: *which authorization server do you trust?* It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one.
An enterprise wants neither decided per server. It already runs an identity provider (Okta, Microsoft Entra ID, your own); the user already signed in to it this morning; and it is the one place the security team wants to decide who may reach what. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), the **Enterprise-Managed Authorization** extension, moves the decision there. The IdP signs a short-lived JWT, an **Identity Assertion JWT Authorization Grant**, the **ID-JAG**: a statement that *this user*, through *this client*, may reach *this MCP server*. The client trades it for an ordinary access token. No browser, no consent screen, no dynamic registration.
-This chapter is both ends of that trade. The MCP server itself never changes: it is still the resource server from **[Authorization](../run/authorization.md)**, checking whatever token shows up.
+This page is both ends of that trade. The MCP server itself never changes: it is still the resource server from **[Authorization](../run/authorization.md)**, checking whatever token shows up.
## Two token requests
-Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **[Authorization](../run/authorization.md)**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In the flows you already know, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first.
+Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **[Authorization](../run/authorization.md)**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In an ordinary OAuth flow, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first.
The client makes one token request to each.
@@ -27,7 +27,7 @@ Everything below is the second request: the client that sends it and the authori
Read it from the bottom.
-* `main()` is the `main()` from **[OAuth clients](oauth-clients.md)**, line for line. That is the point: once the provider exists, nothing downstream knows which grant produced the token.
+* `main()` is the standard OAuth-client `main()` (**[OAuth clients](oauth-clients.md)**), unchanged line for line. That is the point: once the provider exists, nothing downstream knows which grant produced the token.
* The provider takes what the other providers cannot discover: a `client_id` and `client_secret` somebody **pre-registered** with the authorization server, that authorization server's `issuer`, and `assertion_provider`, an async callback that returns a fresh ID-JAG on demand.
* `storage` is the same `TokenStorage` protocol. Only the two token methods are ever called; there is no dynamic registration here, so there is no `client_info` to remember.
diff --git a/docs/client/index.md b/docs/client/index.md
index 2fef17068..afc90d879 100644
--- a/docs/client/index.md
+++ b/docs/client/index.md
@@ -12,7 +12,7 @@ It is one object with one lifecycle: construct it, enter `async with`, call meth
The server at the top is only there so you have something to connect to. The client is the five highlighted lines.
-* `Client(mcp)` is given the **server object itself**. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example in this chapter, and every test you write, connects.
+* `Client(mcp)` is given the **server object itself**. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example on this page, and every test you write, connects.
* `async with` is the **lifecycle**. Entering it connects and negotiates; leaving it disconnects. There is no `connect()` / `close()` pair, and a `Client` cannot be reused after the block ends.
* Inside the block the connection facts are already there as plain properties.
@@ -24,7 +24,7 @@ The server at the top is only there so you have something to connect to. The cli
* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path.
* A **transport**: anything you can `async with ... as (read, write)`, such as `stdio_client(...)` wrapping a subprocess.
-Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own chapter: **[Client transports](transports.md)**.
+Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own page: **[Client transports](transports.md)**.
### What's on a connected client
@@ -104,7 +104,7 @@ That is why `main` narrows with `isinstance(block, TextContent)` before touching
`structured_content` is the tool's return value as JSON, matching the tool's declared `output_schema`. No string parsing, no guessing.
-When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **[Structured Output](../servers/structured-output.md)** chapter.
+When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **[Structured Output](../servers/structured-output.md)** page.
### `is_error`: whether the tool failed
@@ -181,7 +181,7 @@ A server with a completion handler can autocomplete prompt and resource-template
* `ref` says *which* prompt or template you're filling in: a `PromptReference` or a `ResourceTemplateReference`.
* `argument` is `{"name": ..., "value": ...}`: the argument and what the user has typed so far.
-The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **[Completions](../servers/completions.md)** chapter.
+The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **[Completions](../servers/completions.md)** page.
## Pagination
@@ -197,7 +197,7 @@ This loop is correct against every server. `MCPServer` returns everything in one
`Client(mcp)` with no process and no port is already a test harness for your server.
-There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../get-started/testing.md)** is the chapter that explains it and builds the whole pattern around it.
+There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../get-started/testing.md)** is the page that explains it and builds the whole pattern around it.
## Recap
diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md
index 3cfd57866..119d74946 100644
--- a/docs/client/oauth-clients.md
+++ b/docs/client/oauth-clients.md
@@ -4,7 +4,7 @@ Some MCP servers are protected. Send them a request without a token and they ans
**`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx.Auth`, the standard httpx hook for "do something to every request". You attach it to an `httpx.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it.
-This chapter is the client side. Making your own server demand a token is **[Authorization](../run/authorization.md)**.
+This page is the client side. Making your own server demand a token is **[Authorization](../run/authorization.md)**.
## The provider
@@ -87,9 +87,9 @@ You wrote none of it. Three keyword arguments remain (`timeout`, `client_metadat
### 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.
+Every other example in these docs you can check with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server.
-The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this 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.
+The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this page's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by.
## Machine to machine
@@ -119,7 +119,7 @@ By default the secret travels as HTTP Basic auth on the token request (`client_s
the same pattern: construct one, put it on `auth=`. The same module ships
`SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion.
-There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own chapter, **[Identity assertion](identity-assertion.md)**.
+There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own page, **[Identity assertion](identity-assertion.md)**.
## When it fails
diff --git a/docs/client/transports.md b/docs/client/transports.md
index 11b10285d..5554bdd46 100644
--- a/docs/client/transports.md
+++ b/docs/client/transports.md
@@ -18,7 +18,7 @@ No subprocess, no port, no bytes on a wire. The client and the server are two ob
That makes it two things at once:
-* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../get-started/testing.md)** chapter builds the whole pattern around it.
+* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../get-started/testing.md)** page builds the whole pattern around it.
* **An embedding API.** An application that constructs the server doesn't need a network hop to call its tools.
## Streamable HTTP
diff --git a/docs/get-started/first-steps.md b/docs/get-started/first-steps.md
index 97bbbc069..b35cb19d3 100644
--- a/docs/get-started/first-steps.md
+++ b/docs/get-started/first-steps.md
@@ -1,8 +1,8 @@
# First steps
-On the landing page you wrote a server, ran it, and called a tool.
+The **[landing page](../index.md)** moves fast: write a server, run it, call a tool.
-Now do it again, slowly, with all three things a server can expose, and the names for everything you just saw.
+This page takes it slowly, with all three things a server can expose, and a name for everything along the way.
## Host, client, and server
@@ -12,7 +12,7 @@ Three words you'll see on every page from here on:
* A **client** lives inside the host and speaks MCP. The host runs one client per server it's connected to.
* A **server** is what you build with this SDK. It exposes things to clients. It never talks to the model directly.
-You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later in this chapter.
+You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later on this page.
## The three primitives
@@ -70,7 +70,7 @@ Hello, World!
**Prompts.** One entry: `summarize`, with a single required `text` argument. Get it with some text and you receive one message with `role: user` and your rendered string as the content. That's all a prompt is: a function that builds messages.
-The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **[Running your server](../run/index.md)** is the chapter for that.
+The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **[Running your server](../run/index.md)** is the page for that.
## Capabilities
diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md
index eaf8a338c..061e41f0f 100644
--- a/docs/get-started/testing.md
+++ b/docs/get-started/testing.md
@@ -78,7 +78,7 @@ Two different things can go wrong, and this flag only touches one of them.
An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with
`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or
-without it, `call_tool` returns the same `is_error=True` result. There's a whole chapter on it:
+without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it:
**[Handling errors](../servers/handling-errors.md)**.
A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the
diff --git a/docs/handlers/context.md b/docs/handlers/context.md
index 14a886da2..8f8971a81 100644
--- a/docs/handlers/context.md
+++ b/docs/handlers/context.md
@@ -66,7 +66,7 @@ The injected object is small. Besides `request_id`:
* `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 chapter on why.
+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
@@ -124,6 +124,6 @@ On a 2026-07-28 connection, clients receive change notifications only on a `subs
* `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 chapter.
+* Progress reporting and elicitation also start at `Context`; each has its own page.
Next: parameters the model never sees, filled by your own functions, in **[Dependencies](dependencies.md)**.
diff --git a/docs/handlers/elicitation.md b/docs/handlers/elicitation.md
index 89ea317c6..c23e74ce8 100644
--- a/docs/handlers/elicitation.md
+++ b/docs/handlers/elicitation.md
@@ -17,7 +17,7 @@ There are two modes:
--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 chapter: **[The Context](context.md)**.
+* 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.
@@ -48,7 +48,7 @@ The client gets your message and, next to it, a JSON Schema generated from the m
}
```
-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 you already used for a tool's arguments in **[Tools](../servers/tools.md)**.
+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
@@ -95,7 +95,7 @@ A parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` befo
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.
-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)** chapter.
+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.
## Send the user to a URL
diff --git a/docs/handlers/progress.md b/docs/handlers/progress.md
index 26e3c453e..009244ec3 100644
--- a/docs/handlers/progress.md
+++ b/docs/handlers/progress.md
@@ -18,7 +18,7 @@ Three arguments, and you decide what they mean:
* `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)** chapter is all about that object; progress is one of the things it gives you.
+`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
@@ -52,7 +52,7 @@ The callback is an `async` function taking exactly what the server reported: `pr
!!! info
`Client(mcp)` connects straight to the server object, in memory, the same client the **[Testing](../get-started/testing.md)**
- chapter is built on. `progress_callback` is the same parameter whatever transport the `Client`
+ 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
diff --git a/docs/index.md b/docs/index.md
index 26687d470..08fcd5d2e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -90,6 +90,8 @@ You wrote two Python functions with type hints and a docstring. The SDK does the
## Where to go next
* **[Get started](get-started/index.md)** takes you from install to a working, tested server.
+* Building an application that *uses* MCP servers? Start with **[Clients](client/index.md)**.
+* Already have a FastAPI or Starlette app? **[Add to an existing app](run/asgi.md)** mounts an MCP server inside it.
* 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.
* Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format:
diff --git a/docs/migration.md b/docs/migration.md
index bb5eb3d69..e52b0c1d1 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -1512,7 +1512,7 @@ Behavior changes:
Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions.
-Tasks are expected to return as a separate MCP extension in a future release.
+The 2026-07-28 revision reintroduces Tasks as an official extension — [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet.
## Deprecations
diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md
index 80bd5acf2..221a87dc4 100644
--- a/docs/protocol-versions.md
+++ b/docs/protocol-versions.md
@@ -4,7 +4,7 @@ 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.
+You almost never have to care, because `Client` negotiates for you. This page is about the one constructor argument that controls it, `mode=`, and the three times you change it.
## `mode="auto"`
diff --git a/docs/servers/completions.md b/docs/servers/completions.md
index 98665ef3b..ed50a728c 100644
--- a/docs/servers/completions.md
+++ b/docs/servers/completions.md
@@ -39,7 +39,7 @@ Add **one** function decorated with `@mcp.completion()`:
### Try it
-Drive it with the in-memory `Client`, the same one you use in **[Testing](../get-started/testing.md)**. Call
+Drive it with the in-memory `Client` from **[Testing](../get-started/testing.md)**. Call
`client.complete()` with `ref=PromptReference(name="review_code")` and
`argument={"name": "language", "value": "py"}`:
diff --git a/docs/servers/handling-errors.md b/docs/servers/handling-errors.md
index cf30d4fa0..b932150b9 100644
--- a/docs/servers/handling-errors.md
+++ b/docs/servers/handling-errors.md
@@ -4,7 +4,7 @@ A tool can fail in two ways, and the SDK treats them very differently.
Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it.
-This chapter is about choosing.
+This page is about choosing.
## An error the model can fix
@@ -110,7 +110,7 @@ Notice there is no `is_error=True` half-result here. A resource read either retu
A bad argument never reaches your function.
-Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. You saw this in **[Tools](tools.md)** with `Field(le=50)`.
+Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. **[Tools](tools.md)** shows the same rejection with a `Field(le=50)` constraint.
It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.
diff --git a/docs/servers/media.md b/docs/servers/media.md
index ae2505572..eea517dba 100644
--- a/docs/servers/media.md
+++ b/docs/servers/media.md
@@ -30,7 +30,7 @@ Two things to notice:
!!! info
`ImageContent` and `AudioContent` live in `mcp_types`, right next to the `TextContent`
- you met in **[Tools](tools.md)**. A tool result is a list of content blocks; `Image` and `Audio` are
+ a plain `str` result becomes (**[Tools](tools.md)**). A tool result is a list of content blocks; `Image` and `Audio` are
the shortest way to produce the two binary kinds.
### Try it
diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md
index f966c32e2..1105a0193 100644
--- a/docs/servers/prompts.md
+++ b/docs/servers/prompts.md
@@ -12,7 +12,7 @@ You declare one by putting `@mcp.prompt()` on a function that returns the text.
--8<-- "docs_src/prompts/tutorial001.py"
```
-The SDK reads the same three things it read from your tools:
+The SDK reads the same three things it reads from a tool:
* The **name** is the function name: `review_code`.
* The **description** the client shows is the docstring: `Review a piece of code.`
@@ -116,7 +116,7 @@ Notice the last one. Pre-filling an `assistant` turn is how you steer the model'
```
* `title="Code review"` is the human-readable name, exactly like a tool's `title`.
-* `Annotated[str, Field(description=...)]` is the same pattern you used in **[Tools](tools.md)**. Here the description lands on the argument instead of in a schema.
+* `Annotated[str, Field(description=...)]` is the same pattern **[Tools](tools.md)** uses to describe a tool's parameters. Here the description lands on the argument instead of in a schema.
* `language` has a default, so it stops being required.
The `prompts/list` entry now carries everything a client needs to draw a good form:
diff --git a/docs/servers/resources.md b/docs/servers/resources.md
index a6437ee83..407f1d9ae 100644
--- a/docs/servers/resources.md
+++ b/docs/servers/resources.md
@@ -94,7 +94,7 @@ Notice the `uri` in the result. It is the **concrete** URI the client asked for,
The placeholder syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` for multi-segment values, `{?q,lang}` for optional query parameters, and more. The SDK also applies path-safety checks to extracted values by default. See **[URI templates and path safety](uri-templates.md)** for the full reference.
-`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **[The Context](../handlers/context.md)** chapter covers what it gives you.
+`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **[The Context](../handlers/context.md)** page covers what it gives you.
## What you return
diff --git a/docs/servers/structured-output.md b/docs/servers/structured-output.md
index 65ab1794e..a146e0144 100644
--- a/docs/servers/structured-output.md
+++ b/docs/servers/structured-output.md
@@ -1,8 +1,8 @@
# Structured Output
-In **[Tools](tools.md)** you returned a `str` and the result came back twice: as text in `content`, and as `{"result": "..."}` in `structured_content`.
+A tool that returns a plain `str` produces the result twice: as text in `content`, and as `{"result": "..."}` in `structured_content`.
-This chapter is about that second channel: where it comes from, every shape it can take, and how the SDK keeps it honest.
+This page is about that second channel: where it comes from, every shape it can take, and how the SDK keeps it honest.
The short version: **the return type annotation is the output schema**. You already wrote it.
@@ -14,7 +14,7 @@ The short version: **the return type annotation is the output schema**. You alre
The line that matters is the signature: `-> int`.
-Because of it, the tool the SDK sends during `tools/list` carries an `output_schema` next to the input schema you met in **[Tools](tools.md)**:
+Because of it, the tool the SDK sends during `tools/list` carries an `output_schema` next to the input schema it builds from your parameters (**[Tools](tools.md)** covers that one):
```json
{
diff --git a/docs/servers/tools.md b/docs/servers/tools.md
index 120b96e00..bf2e0e4a2 100644
--- a/docs/servers/tools.md
+++ b/docs/servers/tools.md
@@ -49,7 +49,7 @@ result.structured_content # {'result': "Found 3 books matching 'dune' (showing
`content` is the text the **model** reads. `structured_content` is typed data for the **client application**. It's there because you declared the return type as `-> str`.
-Don't worry about `structured_content` yet. Return real Python objects from your tools and the right thing happens; the **[Structured Output](structured-output.md)** chapter is all about it.
+Don't worry about `structured_content` yet. Return real Python objects from your tools and the right thing happens; the **[Structured Output](structured-output.md)** page is all about it.
### Try it
diff --git a/docs/servers/uri-templates.md b/docs/servers/uri-templates.md
index 017c70376..6cda30eb3 100644
--- a/docs/servers/uri-templates.md
+++ b/docs/servers/uri-templates.md
@@ -17,7 +17,7 @@ details (message formats, lifecycle, pagination) see the
## The full operator set
-**[Resources](resources.md)** showed one placeholder, `{user_id}`. There are four more
+The plain placeholder, `{user_id}`, is the one **[Resources](resources.md)** introduces. There are four more
operator forms; here they are on one server so you can see them next to
each other:
@@ -30,7 +30,7 @@ The sections below walk them top to bottom.
### Simple expansion: `{name}`
-`books://{isbn}` is the form you already know. The placeholder maps to
+`books://{isbn}` is the plain, everyday form. The placeholder maps to
the `isbn` parameter, so a client reading `books://978-0441172719` calls
`get_book("978-0441172719")`.
From 3a6f6a62420bdefae838b7ae90dcc123506bff24 Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 17:03:15 +0000
Subject: [PATCH 06/15] docs: drop the 'Next:' course framing from the
page-bottom hand-offs
Ten pages closed with a 'Next: ...' / '... is next' hand-off to the page
that used to follow them in the retired linear read-through. The pointer
and the link are worth keeping -- the word 'Next' is not: it tells a
reader who arrived at one page from a search engine that they are on a
course, which is exactly the tutorial framing this series of changes
removes. Each hand-off keeps its full sentence and its link and loses
only the sequencing word:
Next: telling connected clients that something changed ... with
[Subscriptions].
-> Telling connected clients that something changed ... is
[Subscriptions].
The one 'Next:' inside Get started (first-steps.md) is deliberately
untouched: that section IS a guided sequence, and there the word is
correct.
---
docs/client/callbacks.md | 2 +-
docs/client/index.md | 2 +-
docs/handlers/context.md | 2 +-
docs/handlers/dependencies.md | 2 +-
docs/handlers/lifespan.md | 2 +-
docs/handlers/logging.md | 2 +-
docs/handlers/progress.md | 2 +-
docs/servers/completions.md | 2 +-
docs/servers/prompts.md | 2 +-
docs/servers/tools.md | 2 +-
10 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md
index cc77d241f..e3ef721e2 100644
--- a/docs/client/callbacks.md
+++ b/docs/client/callbacks.md
@@ -144,4 +144,4 @@ Two more. Neither declares anything.
* `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead.
* `logging_callback` and `message_handler` receive notifications. They declare nothing.
-Next: the first argument you've been passing to `Client(...)` all along, **[Client transports](transports.md)**.
+The first argument to `Client(...)` is a transport object — **[Client transports](transports.md)** covers every kind.
diff --git a/docs/client/index.md b/docs/client/index.md
index afc90d879..01287da05 100644
--- a/docs/client/index.md
+++ b/docs/client/index.md
@@ -209,4 +209,4 @@ There is one constructor flag built for that: `Client(mcp, raise_exceptions=True
* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, and `complete` round out the verbs.
* Every `list_*` takes `cursor=`; loop until `next_cursor` is `None`.
-Next: the things a server can ask the *client* for, and how you answer, in **[Client callbacks](callbacks.md)**.
+The things a server can ask the *client* for, and how you answer them, are **[Client callbacks](callbacks.md)**.
diff --git a/docs/handlers/context.md b/docs/handlers/context.md
index 8f8971a81..a6088d372 100644
--- a/docs/handlers/context.md
+++ b/docs/handlers/context.md
@@ -126,4 +126,4 @@ On a 2026-07-28 connection, clients receive change notifications only on a `subs
* `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.
-Next: parameters the model never sees, filled by your own functions, in **[Dependencies](dependencies.md)**.
+Parameters the model never sees, filled by your own functions, are **[Dependencies](dependencies.md)**.
diff --git a/docs/handlers/dependencies.md b/docs/handlers/dependencies.md
index 2b01b3075..9ab9dc449 100644
--- a/docs/handlers/dependencies.md
+++ b/docs/handlers/dependencies.md
@@ -142,4 +142,4 @@ That's the right default for a precondition: no answer, no order. When declining
* 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.
-Next: state your server builds once at startup, and how a handler reaches it, in the **[Lifespan](lifespan.md)**.
+State your server builds once at startup, and how a handler reaches it, is the **[Lifespan](lifespan.md)**.
diff --git a/docs/handlers/lifespan.md b/docs/handlers/lifespan.md
index 18d540bc0..35b9bd080 100644
--- a/docs/handlers/lifespan.md
+++ b/docs/handlers/lifespan.md
@@ -99,4 +99,4 @@ Strip the server down to the lifecycle: give `Database` a `connected` flag, flip
* `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`.
-Next: a handler that stops mid-call to ask the user for something only they know, in **[Elicitation](elicitation.md)**.
+A handler that stops mid-call to ask the user for something only they know is **[Elicitation](elicitation.md)**.
diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md
index 5479fa64c..bd34ec3a9 100644
--- a/docs/handlers/logging.md
+++ b/docs/handlers/logging.md
@@ -75,4 +75,4 @@ went to standard error: the terminal, not the wire.
* 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.
-Next: telling connected clients that something on your server changed — the tool list, a resource — with **[Subscriptions](subscriptions.md)**.
+Telling connected clients that something on your server changed — the tool list, a resource — is **[Subscriptions](subscriptions.md)**.
diff --git a/docs/handlers/progress.md b/docs/handlers/progress.md
index 009244ec3..57bbb59e0 100644
--- a/docs/handlers/progress.md
+++ b/docs/handlers/progress.md
@@ -114,4 +114,4 @@ The callback receives `total=None`. A client can still show *activity* ("3 impor
* 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)** is next.
+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)**.
diff --git a/docs/servers/completions.md b/docs/servers/completions.md
index ed50a728c..795a27457 100644
--- a/docs/servers/completions.md
+++ b/docs/servers/completions.md
@@ -122,4 +122,4 @@ Drop `context_arguments=` and the same call returns `[]`. The handler can't know
* `context.arguments` holds the already-resolved values; the client supplies them as `context_arguments=`.
* The `completions` capability appears the moment you register the handler. Without it, the request is `Method not found`.
-Suggestions help while the user is still *filling in* a prompt or template; to ask them a question in the *middle* of a tool call, you want **[Elicitation](../handlers/elicitation.md)**. Next: everything a tool can return besides text, in **[Images, audio & icons](media.md)**.
+Suggestions help while the user is still *filling in* a prompt or template; to ask them a question in the *middle* of a tool call, you want **[Elicitation](../handlers/elicitation.md)**. Everything a tool can return besides text is **[Images, audio & icons](media.md)**.
diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md
index 1105a0193..c49860dfd 100644
--- a/docs/servers/prompts.md
+++ b/docs/servers/prompts.md
@@ -147,4 +147,4 @@ The `prompts/list` entry now carries everything a client needs to draw a good fo
* `title=` and `Field(description=...)` are what a client puts in its UI.
* A missing required argument fails the whole request. There is no per-prompt error result.
-Next up: server-side autocomplete for a prompt's (or a resource template's) arguments, in **[Completions](completions.md)**.
+Server-side autocomplete for a prompt's (or a resource template's) arguments is **[Completions](completions.md)**.
diff --git a/docs/servers/tools.md b/docs/servers/tools.md
index bf2e0e4a2..8b7ee0572 100644
--- a/docs/servers/tools.md
+++ b/docs/servers/tools.md
@@ -169,4 +169,4 @@ A well-behaved client uses them to decide things like *"do I need to ask the use
* Bad arguments are rejected for you, with an error the model can read and recover from.
* `async def` for I/O, plain `def` for everything else.
-Next up, **[Structured Output](structured-output.md)**: what happens to the value you `return`.
+**[Structured Output](structured-output.md)** is what happens to the value you `return`.
From e742faa94c712d4c6f7a64310632f7c213a20e4c Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 17:03:16 +0000
Subject: [PATCH 07/15] docs: era-accurate wording, a top-of-page auth router,
and CIMD
Three unrelated small fixes on existing pages.
Era wording. Five sentences described a legacy mechanism as a universal
truth. At protocol revision 2026-07-28 there is no initialize handshake
(the client sends one server/discover probe), so 'the server's half of
the handshake', 'advertised it during the handshake', 'icons arrive
during the handshake', and -- best of all -- 'the [server/discover]
result is cacheable' being explained as 'the handshake result' were each
wrong for a modern connection. Each is reworded to the era-neutral truth
(capabilities are declared to every connecting client, however it
connected). Pages ABOUT the handshake (Protocol versions, Session
groups, the migration guide, the two OAuth 'handshakes' that are not
MCP's) are deliberately untouched.
Auth router. authorization.md and oauth-clients.md are the two halves
of one flow and the most-confused pair in the docs. oauth-clients.md
already corrects a wrong-lander in its opening lines ('This page is the
client side. Making your own server demand a token is Authorization.');
authorization.md only did so in its final line. It now has the mirror
sentence up top, so someone who clicked the wrong one finds out in
sentence three, not paragraph forty.
CIMD. The 2026-07-28 revision deprecates OAuth Dynamic Client
Registration in favor of Client ID Metadata Documents, and the SDK
already implements the client side (client_metadata_url= on
OAuthClientProvider) -- but the page never said so: it presented
dynamic registration as THE registration mechanism and named
client_metadata_url once, in passing, with no explanation. A short
section now covers what CIMD is, the one argument that enables it, the
exact condition under which it is used, that the fallback to dynamic
registration is silent, and the construction-time ValueError on a bad
URL. Deliberately prose-only: the SDK's own authorization server cannot
advertise CIMD support, so no runnable docs example can honestly
demonstrate the selection.
---
docs/client/caching.md | 2 +-
docs/client/oauth-clients.md | 12 ++++++++++--
docs/get-started/first-steps.md | 4 ++--
docs/run/asgi.md | 2 +-
docs/run/authorization.md | 2 ++
docs/servers/completions.md | 2 +-
docs/servers/media.md | 2 +-
7 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/docs/client/caching.md b/docs/client/caching.md
index 68cb00c15..5e0976fb5 100644
--- a/docs/client/caching.md
+++ b/docs/client/caching.md
@@ -13,7 +13,7 @@ Out of the box every result says `ttlMs: 0, cacheScope: "private"`: immediately
* The map is keyed by **method name**, and the six cacheable methods are the only legal keys. The parameter is typed `Mapping[CacheableMethod, CacheHint]`, so your editor autocompletes the keys and flags a typo before you run; anything that slips past the type checker raises at construction.
* A method you don't mention keeps the defaults. The map is a set of overrides, not a manifest.
* `CacheHint(ttl_ms=5_000)` left `scope` unset, so it stays `"private"`: five seconds of freshness, per caller. Scope and TTL are independent decisions.
-* `"server/discover"` is a legal key too, since the handshake result is cacheable like any list.
+* `"server/discover"` is a legal key too, since the discovery result is cacheable like any list.
!!! warning
`cacheScope: "public"` means *anyone* may be served your cached response. A shared
diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md
index 119d74946..c52ea8fd7 100644
--- a/docs/client/oauth-clients.md
+++ b/docs/client/oauth-clients.md
@@ -83,7 +83,7 @@ The first time `Client` sends a request, the server answers `401`. The provider
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.
+You wrote none of it. Three keyword arguments remain (`timeout`, `client_metadata_url` and `validate_resource_url`), and this file needs none of them. `client_metadata_url` is the one worth knowing about; it gets its own section below.
### Try it
@@ -91,6 +91,14 @@ Every other example in these docs you can check with an in-memory `Client(server
The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this page's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by.
+## Client ID Metadata Documents
+
+The 2026-07-28 revision of the spec deprecates dynamic client registration in favor of **Client ID Metadata Documents** (CIMD). Instead of POSTing a fresh registration to every authorization server it meets, your client publishes one JSON document about itself at a stable HTTPS URL, and that URL *is* its `client_id`. The authorization server fetches the document; the provider never touches it.
+
+The SDK already speaks it: pass the URL as `client_metadata_url=` when you construct the provider. When the authorization server's metadata advertises `client_id_metadata_document_supported: true`, the provider skips the `/register` request entirely — the URL goes into the flow as the `client_id`, and there is no `client_secret`. When the server doesn't advertise it (most don't yet), or you never pass a URL, the provider falls back to dynamic registration **silently**, and everything above works exactly as described. Stored `client_info` still wins over both.
+
+The URL must be HTTPS with a non-root path; anything else is a `ValueError` at construction, before any network happens. The shipped `examples/clients/simple-auth-client/` takes it as the `MCP_CLIENT_METADATA_URL` environment variable.
+
## Machine to machine
A nightly job, a CI step, another service. There is no browser and nobody to click "allow". That is the **client credentials** grant: you already hold a `client_id` and a `client_secret`, and the token endpoint is the whole flow.
@@ -132,7 +140,7 @@ Not everything is a flow error. The network can still fail; those are ordinary `
* `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.
+* Discovery, registration (dynamic, or via a **Client ID Metadata Document**), PKCE, the `state` and `iss` checks, and token refresh are the provider's job, not yours.
* `ClientCredentialsOAuthProvider` is the no-human version: `client_id` + `client_secret`, no handlers, no browser.
* Every OAuth failure is an `OAuthFlowError`; `OAuthRegistrationError` and `OAuthTokenError` are its subclasses.
diff --git a/docs/get-started/first-steps.md b/docs/get-started/first-steps.md
index b35cb19d3..4e3d37127 100644
--- a/docs/get-started/first-steps.md
+++ b/docs/get-started/first-steps.md
@@ -100,7 +100,7 @@ asyncio.run(main())
{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}}
```
-That dictionary is the server's half of the handshake:
+That dictionary is your server's declared **capabilities** — the first thing every connecting client learns:
| Capability | The client may now call |
|-------------|------------------------------------------------------------|
@@ -123,7 +123,7 @@ Look back over this page. You wrote three small Python functions. You did **not*
* A JSON Schema. `a: int, b: int` *is* the schema for `add`.
* A request handler. `tools/list`, `resources/read`, `prompts/get`: all served for you.
* A capability declaration. `MCPServer` made it for you.
-* A line of protocol. The handshake, the version negotiation, the JSON-RPC framing: all of it happened inside `mcp dev` and `Client(mcp)`, and you never saw it.
+* A line of protocol. The version negotiation, the JSON-RPC framing, the capability exchange: all of it happened inside `mcp dev` and `Client(mcp)`, and you never saw it.
That ratio is the whole point of the SDK.
diff --git a/docs/run/asgi.md b/docs/run/asgi.md
index 2a1e95e50..6facd8283 100644
--- a/docs/run/asgi.md
+++ b/docs/run/asgi.md
@@ -150,7 +150,7 @@ A browser-based client needs two permissions from you: to **send** its MCP reque
* The handler is plain Starlette: an `async` function from `Request` to `Response`.
* `streamable_http_app()` picks up every custom route. `app.routes` is now `/mcp` and `/health`.
-* `GET /health` answers `{"status": "ok"}` with no MCP in sight: no session, no handshake.
+* `GET /health` answers `{"status": "ok"}` with no MCP in sight.
!!! warning
Custom routes are **never authenticated**, even when the rest of the server is. That is
diff --git a/docs/run/authorization.md b/docs/run/authorization.md
index 053bb755a..eb21784f3 100644
--- a/docs/run/authorization.md
+++ b/docs/run/authorization.md
@@ -4,6 +4,8 @@ Over Streamable HTTP your MCP server is an ordinary web service, and you protect
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.
+This page is the server side. A client that discovers your authorization server and fetches the token is **[OAuth clients](../client/oauth-clients.md)**.
+
## 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).
diff --git a/docs/servers/completions.md b/docs/servers/completions.md
index 795a27457..b7b8750fc 100644
--- a/docs/servers/completions.md
+++ b/docs/servers/completions.md
@@ -72,7 +72,7 @@ Registering the handler is the declaration. Connect a client and look:
client.server_capabilities.completions # CompletionsCapability()
```
-You didn't list `completions` anywhere. The SDK saw the handler and advertised it during the handshake. Every *optional* capability works this way: the handler is the declaration. (The three primitives are not optional: `MCPServer` always declares those, handlers or not.)
+You didn't list `completions` anywhere. The SDK saw the handler and declared the capability for you. Every *optional* capability works this way: the handler is the declaration. (The three primitives are not optional: `MCPServer` always declares those, handlers or not.)
!!! check
Go back to the first `server.py` (the one with no handler) and ask it anyway. The call fails
diff --git a/docs/servers/media.md b/docs/servers/media.md
index eea517dba..5f55ee30f 100644
--- a/docs/servers/media.md
+++ b/docs/servers/media.md
@@ -89,7 +89,7 @@ The same `icons=[...]` keyword is accepted by `MCPServer(...)`, `@mcp.tool()`, `
### Where a client sees them
-Icons travel with whatever they decorate. The server's arrive during the handshake, on `client.server_info`:
+Icons travel with whatever they decorate. The server's arrive when the client connects, on `client.server_info`:
```python
client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])]
From b6e46896f21748e36ec14dda23847bc968874bcc Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 17:10:15 +0000
Subject: [PATCH 08/15] docs: lead the elicitation page with the resolver, not
the legacy verb
The page taught ctx.elicit() first and unqualified, with the resolver
second (under a heading about WHEN it runs, 'Ask before the tool runs')
and the protocol-era constraint disclosed only in an info box inside the
client section, 115 lines down. That is backwards. 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 2025-11-25
and earlier); a Resolve-annotated parameter is deliberately era-portable
-- the SDK sends elicitation/create on a legacy connection and a
multi-round-trip result on a modern one, and the handler never knows the
difference.
The page now opens by naming both ways to ask and saying which one to
reach for; 'Ask with a resolver' is the first section and states the
portability out loud; and the two ctx verbs' section carries the
legacy-connection warning at its top instead of a hundred lines later.
The 'Try it' walkthrough named its server as 'the first one on this
page', which the reorder would have silently falsified; it now names it.
No example changes: the resolver example the page now leads with was
already there and already CI-tested. It was just filed second.
---
docs/handlers/elicitation.md | 61 ++++++++++++++++++++++--------------
1 file changed, 37 insertions(+), 24 deletions(-)
diff --git a/docs/handlers/elicitation.md b/docs/handlers/elicitation.md
index c23e74ce8..9bc5d52e4 100644
--- a/docs/handlers/elicitation.md
+++ b/docs/handlers/elicitation.md
@@ -2,16 +2,47 @@
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 server sends the client a question, the client puts it to the user, and the answer comes back into the same function call.
+**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.
-## Ask with a form
+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.
-`ctx.elicit()` takes a message and a Pydantic model:
+## 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"
@@ -79,24 +110,6 @@ A refusal is not an error. The tool decides what declining means (here, no booki
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a
schema-mismatch error, your `if` never runs.
-## Ask before the tool runs
-
-The booking tool above weaves the question into its own body. When the question is really a *precondition* - confirm before deleting, authenticate before acting - you can lift it out of the tool into a **resolver** and let the framework ask 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.
-
-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.
-
## 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:
@@ -132,7 +145,7 @@ Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(
### Try it
-Start the form-mode `server.py` (the first one on this page) 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.
+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:
@@ -162,10 +175,10 @@ Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit
## Recap
-* `await ctx.elicit(message, schema=Model)` asks mid-call; your tool resumes with the answer.
+* 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_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.
+* `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)**.
From 235f60d26d2c0deb1a32be39d31945191f76c504 Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 18:02:00 +0000
Subject: [PATCH 09/15] docs: add the four pages readers were filing issues
instead of finding
Troubleshooting, Deploy & scale, Connect to a real host, and Serving
legacy clients. Each answers a question the issue tracker shows users
asking repeatedly and the docs never answering, and each follows this
doc set's rule that every code block is an executable file under
docs_src/, included by the page and exercised by the test suite. 44 new
tests; the docs suite goes from 859 to 973.
Troubleshooting (top level). Every heading is the exact text of an
error the SDK produces -- the CLIENT-side text where that is what a
user actually sees -- followed by what it means and the one-move fix,
and every quoted error is reproduced by a test. It opens with the one
that wraps all the others: anyio's "ExceptionGroup: unhandled errors in
a TaskGroup", which every exception escaping `async with Client(...)`
arrives inside, so the first thing the page teaches is to read the
last line of the paste.
Deploy & scale (Running your server). The DNS-rebinding Host allowlist
-- the most-reported deployment failure by a wide margin -- moves here
from "Add to an existing app", which keeps a short warning and a
pointer: it is a deploy gate, not an add-to-my-app concern, and its
config example is now a tested file where before it was the doc set's
one untested inline snippet. Then the two things "more than one worker"
actually changes. A multi-round-trip retry that lands on a different
worker fails with the frozen -32602 "Invalid or expired requestState"
because the default sealing key is os.urandom(32) per process; the fix
is RequestStateSecurity(keys=[...]) shared across instances AND the
same server name on every instance, because the name is the seal's
default audience claim -- the half nobody finds. Change notifications
cross replicas only through a shared SubscriptionBus, a two-method
Protocol you implement over your own pub/sub. Both are proved by tests
that run two server instances in memory, the wrong way and the right
way.
Connect to a real host (Get started). A host needs one thing from you:
the command that starts your server over stdio. One tested server
file, then one short section per host. `mcp install` supports exactly
one host -- Claude Desktop -- so the page shows the exact JSON it
writes and where it writes it, and gives Claude Code, Cursor, and VS
Code their one config block each.
Serving legacy clients (Running your server). The streamable_http_app()
you already deploy serves both protocol eras, routed per request on the
version header; there is nothing to configure and no era knob. What a
legacy client costs you is a session -- so more than one worker means
sticky routing, since sessions live in an in-process dict -- and the
one knob, stateless_http=True, is legacy-leg-only and trades away both
server-to-client channels on that leg. The page's central example is
one server with one Resolve-based tool serving a legacy and a modern
client concurrently, entirely in memory, which is the whole pitch in
one test.
The pages that should route to the new ones now do: the landing page,
the Get started sequence and its index, the Running your server index,
the Testing hand-off, and Handling errors.
---
docs/get-started/first-steps.md | 2 +-
docs/get-started/index.md | 3 +-
docs/get-started/real-host.md | 168 ++++++++++
docs/get-started/testing.md | 5 +-
docs/index.md | 1 +
docs/run/asgi.md | 49 +--
docs/run/authorization.md | 2 +-
docs/run/deploy.md | 174 ++++++++++
docs/run/index.md | 6 +-
docs/run/legacy-clients.md | 120 +++++++
docs/servers/handling-errors.md | 2 +
docs/troubleshooting.md | 412 ++++++++++++++++++++++++
docs_src/deploy/__init__.py | 0
docs_src/deploy/tutorial001.py | 17 +
docs_src/deploy/tutorial002.py | 27 ++
docs_src/deploy/tutorial003.py | 27 ++
docs_src/deploy/tutorial004.py | 23 ++
docs_src/legacy_clients/__init__.py | 0
docs_src/legacy_clients/tutorial001.py | 42 +++
docs_src/legacy_clients/tutorial002.py | 28 ++
docs_src/legacy_clients/tutorial003.py | 21 ++
docs_src/real_host/__init__.py | 0
docs_src/real_host/tutorial001.py | 34 ++
docs_src/troubleshooting/__init__.py | 0
docs_src/troubleshooting/tutorial001.py | 22 ++
docs_src/troubleshooting/tutorial002.py | 15 +
docs_src/troubleshooting/tutorial003.py | 12 +
docs_src/troubleshooting/tutorial004.py | 18 ++
docs_src/troubleshooting/tutorial005.py | 16 +
docs_src/troubleshooting/tutorial006.py | 19 ++
docs_src/troubleshooting/tutorial007.py | 25 ++
docs_src/troubleshooting/tutorial008.py | 23 ++
mkdocs.yml | 4 +
tests/docs_src/test_deploy.py | 226 +++++++++++++
tests/docs_src/test_legacy_clients.py | 136 ++++++++
tests/docs_src/test_real_host.py | 54 ++++
tests/docs_src/test_troubleshooting.py | 278 ++++++++++++++++
tests/test_examples.py | 1 +
38 files changed, 1965 insertions(+), 47 deletions(-)
create mode 100644 docs/get-started/real-host.md
create mode 100644 docs/run/deploy.md
create mode 100644 docs/run/legacy-clients.md
create mode 100644 docs/troubleshooting.md
create mode 100644 docs_src/deploy/__init__.py
create mode 100644 docs_src/deploy/tutorial001.py
create mode 100644 docs_src/deploy/tutorial002.py
create mode 100644 docs_src/deploy/tutorial003.py
create mode 100644 docs_src/deploy/tutorial004.py
create mode 100644 docs_src/legacy_clients/__init__.py
create mode 100644 docs_src/legacy_clients/tutorial001.py
create mode 100644 docs_src/legacy_clients/tutorial002.py
create mode 100644 docs_src/legacy_clients/tutorial003.py
create mode 100644 docs_src/real_host/__init__.py
create mode 100644 docs_src/real_host/tutorial001.py
create mode 100644 docs_src/troubleshooting/__init__.py
create mode 100644 docs_src/troubleshooting/tutorial001.py
create mode 100644 docs_src/troubleshooting/tutorial002.py
create mode 100644 docs_src/troubleshooting/tutorial003.py
create mode 100644 docs_src/troubleshooting/tutorial004.py
create mode 100644 docs_src/troubleshooting/tutorial005.py
create mode 100644 docs_src/troubleshooting/tutorial006.py
create mode 100644 docs_src/troubleshooting/tutorial007.py
create mode 100644 docs_src/troubleshooting/tutorial008.py
create mode 100644 tests/docs_src/test_deploy.py
create mode 100644 tests/docs_src/test_legacy_clients.py
create mode 100644 tests/docs_src/test_real_host.py
create mode 100644 tests/docs_src/test_troubleshooting.py
diff --git a/docs/get-started/first-steps.md b/docs/get-started/first-steps.md
index 4e3d37127..cb9d6d857 100644
--- a/docs/get-started/first-steps.md
+++ b/docs/get-started/first-steps.md
@@ -136,4 +136,4 @@ That ratio is the whole point of the SDK.
* The server's **capabilities** are declared for you, and a client only asks for what a server declares.
* `Client(mcp)` connects to the server object in memory: your test harness from day one.
-Next: **[Testing](testing.md)** — one page, one in-memory client, and you're never guessing whether it works. Then each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**.
+Next: **[Connect to a real host](real-host.md)** — this server inside Claude Desktop or an IDE, for real. Then **[Testing](testing.md)**: one page, one in-memory client, and you're never guessing whether it works. After that, each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**.
diff --git a/docs/get-started/index.md b/docs/get-started/index.md
index 2e7b2799f..262a879d5 100644
--- a/docs/get-started/index.md
+++ b/docs/get-started/index.md
@@ -2,7 +2,8 @@
New to MCP, or new to this SDK? Start here. These pages take you from nothing to a
working, tested server: [install the SDK](installation.md), build your
-[first server](first-steps.md), and [test it](testing.md) with an in-memory client.
+[first server](first-steps.md), [connect it to a real host](real-host.md), and
+[test it](testing.md) with an in-memory client.
## Run the code
diff --git a/docs/get-started/real-host.md b/docs/get-started/real-host.md
new file mode 100644
index 000000000..cec7f3d03
--- /dev/null
+++ b/docs/get-started/real-host.md
@@ -0,0 +1,168 @@
+# Connect to a real host
+
+A **host** is the application your server ends up inside: Claude Desktop, Claude Code, an IDE. The host is what the user talks to. Inside it, an MCP **client** launches your server as a child process and speaks to it over that process's stdin and stdout.
+
+Which means connecting to a host is one act: you tell it **the command that starts your server**. Everything on this page — two CLI commands, three JSON files — is a different place to put that same command.
+
+## One server, every host
+
+```python title="server.py" hl_lines="3 33-34"
+--8<-- "docs_src/real_host/tutorial001.py"
+```
+
+Two tools and a resource, one file. Two things about that file matter to every host below:
+
+* `run()` is under `if __name__ == "__main__":`. Everything below **imports** this file rather than executing it, so an unguarded `run()` would start a server the moment anything loaded the module.
+* The server object is a module-level global named `mcp`. That's the name `mcp run` looks for (`server` and `app` also work). Call it something else and you name it explicitly: `mcp run server.py:bookshop`.
+
+That is the last line of Python on this page. From here down it is all host configuration.
+
+## The launch command
+
+Every host below gets the same command:
+
+```bash
+uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
+```
+
+One command for all of them because `uv run --with` resolves the pinned SDK into a fresh environment on the spot: it works from any directory, needs no project and no virtual environment to activate, and always gets the exact `mcp` version these docs describe. That matters here more than anywhere else, because a host launches your server from *its* working directory with a near-empty environment — not from your shell.
+
+It is also the command `mcp install` writes into Claude Desktop's config for you (below), so what you type by hand and what the tool generates agree.
+
+!!! warning "The version pin is not optional"
+ v2 of this SDK is in beta, and installers never select a pre-release unless you name one. An
+ unpinned `--with "mcp[cli]"` gives you the latest **v1.x**, which these docs do not describe.
+ Use the exact pin from **[Installation](installation.md)**.
+
+!!! tip "If a host can't find `uv`"
+ A host spawns your server with a minimal `PATH`, and `uv` may not be on it. Replace the bare
+ `uv` with the absolute path from `which uv` (macOS/Linux) or `where uv` (Windows). That is
+ exactly what `mcp install` writes.
+
+## Claude Desktop
+
+The one host the SDK can configure for you:
+
+```bash
+uv run mcp install server.py
+```
+
+That's it. `mcp install` imports the file to read the server's name, finds Claude Desktop's config file, and writes the launch command into it — converting your path to an absolute one on the way, so you don't have to.
+
+There is nothing to be mystified by. This is the entry it writes:
+
+```json
+{
+ "mcpServers": {
+ "Bookshop": {
+ "command": "/absolute/path/to/uv",
+ "args": [
+ "run",
+ "--frozen",
+ "--with",
+ "mcp[cli]==2.0.0b1",
+ "mcp",
+ "run",
+ "/absolute/path/to/server.py"
+ ]
+ }
+ }
+}
+```
+
+That's the launch command from the section above with two additions: the absolute path to `uv`, and `--frozen` so `uv` never rewrites a lockfile it happens to be near. It lands in `claude_desktop_config.json`, which lives at:
+
+* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
+* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
+
+You can write that file by hand. `mcp install` exists so you don't make the two classic mistakes (a relative path, a missing version pin) while doing it.
+
+Fully quit Claude Desktop — not just its window — and reopen it.
+
+!!! warning
+ `mcp install` fails with `Claude app not found` if Claude Desktop's config *directory* doesn't
+ exist yet. Install Claude Desktop and run it once — that's what creates the directory.
+
+!!! tip
+ Claude Desktop starts your server in its own process, so your shell's environment variables are
+ not there. `uv run mcp install server.py -v API_KEY=abc123` (or `-f .env`) records them in the
+ entry's `env` field. `--name` overrides the entry name; it defaults to the server's `name`.
+
+## Claude Code
+
+There is no file to edit. Register the server with the `claude` CLI; everything after `--` is the launch command.
+
+```bash
+claude mcp add bookshop -- uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
+```
+
+Run `/mcp` inside a Claude Code session to confirm `bookshop` is connected and its tools are listed.
+
+## Cursor
+
+Create `.cursor/mcp.json` in your project root.
+
+```json
+{
+ "mcpServers": {
+ "bookshop": {
+ "command": "uv",
+ "args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"]
+ }
+ }
+}
+```
+
+The same `command` plus `args`, under the same `mcpServers` key Claude Desktop uses. The server appears in Cursor's MCP settings with both tools listed.
+
+## VS Code
+
+Create `.vscode/mcp.json` in your project root.
+
+```json
+{
+ "servers": {
+ "bookshop": {
+ "type": "stdio",
+ "command": "uv",
+ "args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"]
+ }
+ }
+}
+```
+
+Two differences from Cursor's file, and they are the only two: the wrapper key is `servers`, not `mcpServers`, and each entry declares its `type`. Confirm the trust prompt, then **MCP: List Servers** in the Command Palette shows `bookshop` running.
+
+!!! note
+ You need VS Code 1.99 or later with the **GitHub Copilot** extension signed in (Copilot Free is
+ enough), and Copilot Chat must be in **Agent** mode — the only mode that calls tools.
+
+## It doesn't show up
+
+Before you touch any host config, run the launch command yourself:
+
+```bash
+uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
+```
+
+Nothing prints, and it doesn't return. That silence is correct: a stdio server is waiting for a host to speak first on stdin (`Ctrl-C` to stop it). A traceback or an immediate exit is the real bug, and now you can read it instead of guessing at it through a host.
+
+Once that command sits and waits, what's left is almost always one of three things:
+
+* **A relative path.** The host launches your server from *its* working directory, not the one you registered from. `server.py` where `/absolute/path/to/server.py` is needed is the single most common failure. If the host can't find `uv` either, that path has to be absolute too.
+* **The host is still running its old config.** Hosts read their config at launch. Claude Desktop in particular has to be *fully quit* — not just its window closed — and reopened before an edit to `claude_desktop_config.json` takes effect.
+* **Something reached stdout.** On stdio, stdout *is* the protocol. One stray `print()` and the host reads a corrupt message and drops the connection. Log with the `logging` module — it writes to stderr. **[Logging](../handlers/logging.md)** has the whole story.
+
+Claude Desktop keeps a log per server: `mcp-server-.log` is your server's stderr, next to `mcp.log` for connections, under `~/Library/Logs/Claude` on macOS and `%APPDATA%\Claude\logs` on Windows.
+
+For anything past those three, **[Troubleshooting](../troubleshooting.md)** is the page.
+
+## Recap
+
+* A **host** (Claude Desktop, an IDE) runs an MCP client that launches your server as a child process over stdio. Connecting means giving it one launch command.
+* That command is `uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py`: version-pinned, no venv to activate, works from any directory. The pin is mandatory while v2 is in beta.
+* **Claude Desktop** is the one host `mcp install` configures for you. It writes that same command — plus the absolute path to `uv` — into `claude_desktop_config.json`, so you never have to.
+* **Claude Code** is `claude mcp add bookshop -- `. **Cursor** is `.cursor/mcp.json` under `mcpServers`. **VS Code** is `.vscode/mcp.json` under `servers`, each entry with a `type`.
+* Absolute paths everywhere, restart the host after editing its config, and never let anything but the SDK write to stdout.
+
+Every host on this page connected to the same file, with the same command. What that file can *expose* is the rest of these docs: **[Tools](../servers/tools.md)**, **[Resources](../servers/resources.md)**, and every transport besides stdio in **[Running your server](../run/index.md)**.
diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md
index 061e41f0f..3feceffb0 100644
--- a/docs/get-started/testing.md
+++ b/docs/get-started/testing.md
@@ -102,5 +102,6 @@ That one line is also why these docs can promise you that their examples work: e
example file is exercised by the SDK's own test suite through exactly this client. You're using the
same tool the SDK uses on itself.
-You have a working, tested server. Putting it in front of a real client, over a real
-transport, is **[Running your server](../run/index.md)**.
+You have a working, tested server. Putting it inside a real application — Claude Desktop, an
+IDE — is **[Connect to a real host](real-host.md)**; every other way to serve it is
+**[Running your server](../run/index.md)**.
diff --git a/docs/index.md b/docs/index.md
index 08fcd5d2e..a729cfba2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -92,6 +92,7 @@ You wrote two Python functions with type hints and a docstring. The SDK does the
* **[Get started](get-started/index.md)** takes you from install to a working, tested server.
* Building an application that *uses* MCP servers? Start with **[Clients](client/index.md)**.
* Already have a FastAPI or Starlette app? **[Add to an existing app](run/asgi.md)** mounts an MCP server inside it.
+* Hunting an exact error message? **[Troubleshooting](troubleshooting.md)** is keyed by the verbatim text.
* 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.
* Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format:
diff --git a/docs/run/asgi.md b/docs/run/asgi.md
index 6facd8283..8928fa217 100644
--- a/docs/run/asgi.md
+++ b/docs/run/asgi.md
@@ -37,44 +37,13 @@ Run the app on its own (`uvicorn server:app`) and you never think about either.
## Localhost only, until you say otherwise
-`streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the
-safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding
-protection** and accepts a request only if its `Host` header is `127.0.0.1:`,
-`localhost:`, or `[::1]:`, and only if its `Origin` header, when there is one, is the
-`http://` form of the same. For `uvicorn server:app` on your machine that is exactly what you want:
-it stops a malicious web page from driving your local server through a DNS name it rebound to
-`127.0.0.1`.
-
-It also means that **deployed behind a real hostname, the app rejects every request until you
-configure it**. The check runs before MCP does, the client sees only a generic transport error, and
-the reason is a single warning in the *server's* log:
-
-```text
-421 Misdirected Request Invalid Host header the Host is not in the allowlist
-403 Forbidden Invalid Origin header the Origin is not in the allowlist
-```
-
-`transport_security=` is how you configure it. Allowlist what you actually serve:
-
-```python
-from mcp.server.transport_security import TransportSecuritySettings
-
-security = TransportSecuritySettings(
- allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
- allowed_origins=["https://app.example.com"],
-)
-app = mcp.streamable_http_app(transport_security=security)
-```
-
-* `allowed_hosts` entries are exact strings: `"mcp.example.com"` matches a bare `Host` header and
- `"mcp.example.com:*"` matches any port. List both.
-* `allowed_origins` only matters for browsers (nothing else sends `Origin`). It is the server-side
- twin of the CORS configuration below.
-* Behind a reverse proxy that already controls the `Host` header, switching the check off is the
- honest configuration: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`.
-* Passing a non-localhost `host=` (for example `host="mcp.example.com"`) does **not** allowlist that
- hostname. It only stops the localhost default from arming the protection, which leaves every Host
- and Origin accepted. Say what you mean with `transport_security=` instead.
+Out of the box the app answers **only** requests addressed to localhost. `streamable_http_app()`
+cannot know which hostname it will be served behind, so it arms DNS-rebinding protection with the
+safest possible allowlist; on your machine that is exactly right. Deployed behind a real hostname,
+it means **every request is rejected with `421 Misdirected Request`** until you pass
+`transport_security=` an allowlist of what you actually serve — and nothing you built is even
+consulted first. That allowlist, and everything else between a working app and a real hostname,
+is **[Deploy & scale](deploy.md)**.
## Mounting it
@@ -88,7 +57,7 @@ The moment the MCP server is *part* of a bigger application, you put the app ins
* The `lifespan` function enters `mcp.session_manager.run()` for the lifetime of the **host** app. This is the line everyone forgets.
* `mcp.session_manager` only exists *after* `streamable_http_app()` has been called. That is why the routes are built at module level and the manager is only touched inside the lifespan.
-Starlette's `Host` route works the same way: swap `Mount("/", ...)` for `Host("mcp.example.com", ...)` to route by hostname instead of by path. The lifespan rule does not change, and neither does the transport-security one. A `Host("mcp.example.com", ...)` route only ever receives requests addressed to that hostname, so without `allowed_hosts=["mcp.example.com", "mcp.example.com:*"]` it answers every one of them with a `421`.
+Starlette's `Host` route works the same way: swap `Mount("/", ...)` for `Host("mcp.example.com", ...)` to route by hostname instead of by path. The lifespan rule does not change, and neither does the transport-security one. A `Host("mcp.example.com", ...)` route only ever receives requests addressed to that hostname, but the transport's own Host allowlist (**[Deploy & scale](deploy.md)**) still runs first — without `"mcp.example.com"` in it, that route answers every one of them with a `421`.
!!! warning "The host app owns the lifespan"
`streamable_http_app()` wires `session_manager.run()` into the lifespan of the Starlette it
@@ -160,7 +129,7 @@ A browser-based client needs two permissions from you: to **send** its MCP reque
## Recap
* `mcp.streamable_http_app()` returns a Starlette app with one route, `/mcp`. Any ASGI server can run it.
-* Out of the box the app answers only requests addressed to localhost. Deploying behind a real hostname means passing `transport_security=TransportSecuritySettings(...)`.
+* Out of the box the app answers only requests addressed to localhost, and behind a real hostname it rejects everything with a `421` until you pass `transport_security=` an allowlist. **[Deploy & scale](deploy.md)** owns that, and the rest of the road to production.
* `Mount` (or `Host`) puts it inside a bigger Starlette or FastAPI app.
* **Mounting disables the built-in lifespan.** The host app's lifespan must enter `mcp.session_manager.run()`, or the first request fails.
* Several servers in one app means several mounts and one lifespan that enters every session manager.
diff --git a/docs/run/authorization.md b/docs/run/authorization.md
index eb21784f3..1236da3ff 100644
--- a/docs/run/authorization.md
+++ b/docs/run/authorization.md
@@ -122,4 +122,4 @@ An authorization server can also accept an enterprise identity provider's signed
* `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](../client/oauth-clients.md)**.
+The client half — discovering your authorization server and fetching the token for you — is **[OAuth clients](../client/oauth-clients.md)**. And a client that *asserts* an identity instead of asking a user for one is **[Identity assertion](../client/identity-assertion.md)**.
diff --git a/docs/run/deploy.md b/docs/run/deploy.md
new file mode 100644
index 000000000..0d73a8f81
--- /dev/null
+++ b/docs/run/deploy.md
@@ -0,0 +1,174 @@
+# Deploy & scale
+
+Your server works. Now it needs a real hostname, and more than one worker behind it.
+
+Almost none of that is MCP's business. You bring the ASGI server, the process manager, the load balancer. What this page has is the short list of things that *are* MCP's business: one setting that gates every deployment, and the two places where "more than one worker" changes what the SDK does.
+
+## Before anything else: the Host allowlist
+
+`streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding protection** and accepts a request only if its `Host` header is `127.0.0.1:`, `localhost:`, or `[::1]:` — and only if its `Origin` header, when there is one, is the `http://` form of the same. On your machine that is exactly right: it stops a malicious web page from driving your local server through a DNS name it rebound to `127.0.0.1`.
+
+Deployed behind a real hostname, that same default rejects **every request** until you say otherwise. The check runs before anything MCP-shaped does, so nothing you built is even consulted:
+
+```text
+421 Misdirected Request Invalid Host header the Host is not in the allowlist
+403 Forbidden Invalid Origin header the Origin is not in the allowlist
+```
+
+`transport_security=` is the fix. Allowlist what you actually serve:
+
+```python title="server.py" hl_lines="2 13-17"
+--8<-- "docs_src/deploy/tutorial001.py"
+```
+
+* `allowed_hosts` entries are exact strings: `"mcp.example.com"` matches a bare `Host` header and `"mcp.example.com:*"` matches any port. List both.
+* `allowed_origins` only matters for browsers — nothing else sends `Origin`. It is the server-side twin of the CORS configuration in **[Add to an existing app](asgi.md)**.
+* Behind a reverse proxy that already controls the `Host` header, switching the check off is the honest configuration: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`.
+* Passing a non-localhost `host=` (for example `host="mcp.example.com"`) does **not** allowlist that hostname. It only stops the localhost default from arming the protection, which leaves every Host and Origin accepted. Say what you mean with `transport_security=` instead.
+
+!!! check
+ Delete the `transport_security=security` argument and deploy the app anyway. It starts, `/mcp`
+ routes, and every request — including from a plain `curl` — comes back:
+
+ ```text
+ HTTP/1.1 421 Misdirected Request
+
+ Invalid Host header
+ ```
+
+ You will not find those words on the client side. A `421` is a plain-text HTTP response, not a
+ JSON-RPC error, so the MCP client raises a generic transport error; the hostname it
+ didn't like appears only in the **server's** log, as a single warning. A freshly
+ deployed server that refuses every connection is a Host allowlist until proven otherwise —
+ **[Troubleshooting](../troubleshooting.md)** starts here too.
+
+## Workers, and who has to be sticky
+
+Once the hostname answers, put more than one worker behind it. There is no SDK knob for that; you scale a Starlette app the way you scale any ASGI app, by handing the object to something that knows how to fork:
+
+```console
+uvicorn server:app --workers 4
+```
+
+Four processes, one socket. And now the question every deployment has to answer: **does a request have to reach the worker that saw the last one?**
+
+For a client speaking the **2026-07-28** protocol, no. A modern request is one self-contained POST: no `initialize` handshake before it, no `Mcp-Session-Id` on the response, nothing for a second request to come back *to*. Route it to any worker.
+
+That is not a mode you switch on. `stateless_http=True` looks like it should be, but the transport routes on the `MCP-Protocol-Version` request header, hands a modern request to the modern handler, and **returns** — the line that reads `stateless_http` comes *after* that return. It isn't that the flag is ignored on the 2026-07-28 path; it is never reached. `stateless_http` is a knob for the **legacy** leg only, and the modern path is sessionless by construction.
+
+For a legacy client on spec version 2025-11-25 or earlier, the answer depends on that flag:
+
+| Client's protocol version | Session | What the load balancer must do |
+| --- | --- | --- |
+| **2026-07-28** | None. `Mcp-Session-Id` is never set. | Nothing. Any worker serves any request. |
+| **2025-11-25 and earlier** (the default) | `Mcp-Session-Id`, held in one worker's memory. | **Sticky sessions.** A follow-up that reaches a different worker gets a `404` *"Session not found"*. |
+| **2025-11-25 and earlier**, with `stateless_http=True` | None. | Nothing — at the cost of the server-to-client back-channel (sampling, push elicitation, `roots/list`) and of resumability. |
+
+Sticky sessions and what the legacy leg costs are their own page, **[Serving legacy clients](legacy-clients.md)**; the two eras themselves are **[Protocol versions](../protocol-versions.md)**. What matters here is the shape of the answer: *on 2026-07-28 you are already stateless, with nothing to configure.*
+
+The rest of this page is the two things that being stateless does **not** buy you.
+
+## `requestState` across workers
+
+A **[multi-round-trip](../handlers/multi-round-trip.md)** tool needs something the client has to go get — a confirmation, a choice, a credential — so it returns a question instead of an answer and finishes on the retry. Between the two rounds the client holds an opaque `request_state` token the server minted. On the retry the server has to open that token again.
+
+*Sealed under what key?* By default, one the server generated with `os.urandom(32)` at construction time. Under `--workers 4` that is four constructions, in four processes: four different keys, never written anywhere, never shared, gone on restart.
+
+Here is a tool that asks before it acts, on a server that configures nothing:
+
+```python title="server.py" hl_lines="15 21"
+--8<-- "docs_src/deploy/tutorial002.py"
+```
+
+The first round reaches worker A. Worker A seals `refund:120` under **its** key and returns the token. The client puts the question in front of a person, gets a yes, and retries — a brand-new HTTP request.
+
+!!! check
+ Let that retry reach worker B. B tries to unseal a token it did not mint, cannot, and refuses the
+ whole round. `refund` is never called; the client gets a JSON-RPC error:
+
+ ```json
+ {
+ "code": -32602,
+ "message": "Invalid or expired requestState",
+ "data": {"reason": "invalid_request_state"}
+ }
+ ```
+
+ That message is **frozen**. Expired, tampered with, replayed against different arguments, or — by
+ far the most common cause in a real deployment — sealed by a sibling worker: the client is told
+ the same thing every time, so the wire never reveals which check failed. The real reason is one
+ `WARNING` in the server's log:
+
+ ```text
+ requestState rejected on tools/call: unknown key
+ ```
+
+ A multi-round-trip tool that worked with one worker and started failing *some of the time* at
+ two is this. Both rounds still have to reach the same process, so it fails exactly as often as
+ your load balancer separates them.
+
+The two rounds are two independent HTTP requests, and several ordinary things separate them: a proxy that balances per request, a connection that dropped in between, a deploy or a restart, a client that persisted `request_state` and is resuming from a different process entirely (**[Driving the loop yourself](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Any of them is "a different worker".
+
+The fix is one argument. It has **two** halves.
+
+```python title="server.py" hl_lines="3 13 15"
+--8<-- "docs_src/deploy/tutorial003.py"
+```
+
+* **`keys=[...]`** is the half everyone finds. Give every instance the same secret — at least 32 bytes of it — and every instance can unseal what any sibling minted. `keys[0]` seals and every key in the list unseals, which is the rotation ring; **[Rotating keys](../handlers/multi-round-trip.md#rotating-keys)** is how you turn it without downtime.
+* **The server's name** is the half almost nobody finds, and the reason cross-instance retries still fail after you share the key. Every sealed token carries the server's `name` as an **audience claim**, checked strictly on the way back in. Two instances built from the same code have the same name and never notice it. Name them apart — `MCPServer(f"billing-{POD}")` reads like good observability hygiene — and every cross-instance retry is refused exactly as above, shared key or not. The log says `audience` instead of `unknown key`; the client cannot tell the difference.
+
+Mint the secret once and hand the same value to every instance. This is the command the SDK's own error message tells you to run if you pass it fewer than 32 bytes:
+
+```console
+python -c "import secrets; print(secrets.token_hex(32))"
+```
+
+!!! warning "Same keys, *and* the same name"
+ A multi-instance deployment must share both. If per-instance names are load-bearing for you,
+ give the fleet one explicit audience instead — `RequestStateSecurity(keys=[...], audience="billing")` —
+ and every instance mints and accepts under `"billing"` no matter what it is called.
+
+Everything else about the seal — what it binds, the per-round `ttl` (600 seconds by default), bringing your own codec, why the unconfigured default is exactly right on `stdio` — is **[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**. This page's whole contribution is a two-item checklist: *same keys, same name.*
+
+!!! info
+ You are on this path even if you have never typed `InputRequiredResult`. A tool whose parameters
+ use `Resolve(...)` (**[Dependencies](../handlers/dependencies.md)**) is a multi-round-trip tool,
+ and the SDK mints and seals its `request_state` for it. Same default key, same failure across
+ workers, same fix.
+
+## Change notifications across replicas
+
+A client's `subscriptions/listen` stream is one long-lived response, so it is pinned to one replica for its whole life. A `ctx.notify_resource_updated(...)` published on a **different** replica has to reach it.
+
+The seam between the two is the `SubscriptionBus`. Whatever bus you give a server is the one every publish goes into and every open stream listens on, so hand the same bus to every replica:
+
+```python title="server.py" hl_lines="2 7 9"
+--8<-- "docs_src/deploy/tutorial004.py"
+```
+
+Nothing about the fan-out cares which server object a stream is attached to. Two servers holding one `InMemorySubscriptionBus` already behave this way: open a listen stream on one, `edit_note` on the other, and the stream hears about it. That in-memory bus only spans server objects inside one process, which makes it the model, not the deployment:
+
+* Across real processes, **the SDK ships no bus that can help you.** `SubscriptionBus` is a two-method `Protocol` — `publish` and `subscribe` — that you implement over your own pub/sub backend (Redis, NATS, whatever you already run) and pass as `MCPServer(subscriptions=...)`. **[Subscriptions](../handlers/subscriptions.md#one-process-is-the-default-more-takes-a-bus)** has the sketch and the contract.
+* The bus carries four small typed events, never JSON-RPC. Acknowledgment, filtering, and stream lifecycle stay in the SDK, so your bus cannot break the protocol; it can only move events between processes.
+* Streams are **not** resumable and events are **not** replayed. Losing a replica drops its streams; the clients re-listen and re-fetch. There is no event store to share and nothing else to configure — this is the one place where scaling out is genuinely just more of the same.
+
+## What the SDK does not give you
+
+An `MCPServer` is a protocol implementation, not an application server. The deployment knobs you go looking for next are missing on purpose:
+
+* **No `workers=`.** `mcp.run("streamable-http")` starts exactly one uvicorn process, and that is all it will ever start. Multi-process is `streamable_http_app()` handed to whatever you already deploy ASGI with — `uvicorn --workers`, gunicorn, your platform's process manager. This page is deliberately not a tutorial for any of them; their documentation is better than a copy of it here would be.
+* **No health-check route.** `@mcp.custom_route("/health", methods=["GET"])` is the whole answer, and it is never authenticated even when the rest of the server is — right for a liveness probe, wrong for anything private. **[Add to an existing app](asgi.md#custom-routes)** shows one.
+* **No production settings object.** There is nowhere on `MCPServer` to write down timeouts, TLS, graceful shutdown, or connection limits, because none of those are its job. They belong to your ASGI server, and you configure them there. **[Running your server](index.md)** covers the handful of settings the constructor *does* take.
+* **No shipped `EventStore`, and on 2026-07-28 no use for one.** Resumability is a feature of the legacy stateful leg; a modern exchange is one POST, one response, and nothing to resume.
+
+## Recap
+
+* Out of the box the app answers only requests addressed to localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` is the go-live gate: until you pass it, every request behind a real hostname is a `421` and the reason is only in the server's log.
+* On 2026-07-28 there is no session and nothing for a load balancer to be sticky on. `stateless_http=True` is a legacy-only knob — a modern request is routed and answered before that flag is ever read.
+* The default `requestState` key is `os.urandom(32)`, minted per process. A multi-round-trip retry that reaches a different worker fails with `-32602` *"Invalid or expired requestState"*.
+* The fix is `RequestStateSecurity(keys=[...])` **and** the same server name on every instance — the name is the token's default audience claim. Same keys, same name.
+* Change notifications cross replicas through one shared `SubscriptionBus`. The SDK's only implementation is in-process; the two-method `Protocol` over your own pub/sub is yours to write.
+* There is no `workers=`, no health route, no production settings object. Bring your own ASGI server.
+
+The other thing a real hostname needs in front of it is a token: **[Authorization](authorization.md)**.
diff --git a/docs/run/index.md b/docs/run/index.md
index 322c7a0a8..cd7dc2954 100644
--- a/docs/run/index.md
+++ b/docs/run/index.md
@@ -67,7 +67,7 @@ Each transport has its own keyword arguments, all on `run()`:
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
* `json_response=True`: answer with plain JSON instead of an SSE stream.
* `stateless_http=True`: a fresh transport per request, no session tracking.
-* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[Add to an existing app](asgi.md)** covers `transport_security`.
+* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[Deploy & scale](deploy.md)** covers `transport_security`.
!!! warning
Transport options go to `run()`, **not** to `MCPServer(...)`. The constructor describes what
@@ -127,6 +127,8 @@ uv run mcp install server.py -v API_KEY=abc123 -f .env
`-v KEY=VALUE` and `-f .env` record environment variables in that entry. Claude Desktop starts your server in its own process. Your shell's environment is not there.
+Claude Desktop is the only host `mcp install` knows. Every other host — Claude Code, Cursor, VS Code — takes the same launch command in its own config file, and **[Connect to a real host](../get-started/real-host.md)** has each one.
+
`mcp version` prints the installed SDK version.
!!! tip
@@ -143,4 +145,4 @@ uv run mcp install server.py -v API_KEY=abc123 -f .env
* `mcp dev` for the Inspector, `mcp run` to execute a file, `mcp install` for Claude Desktop, `mcp version` for the version.
* The transport never changes what your server *is*: all three files on this page expose the identical tool.
-When `run()` itself is the limit (your server inside an app that already exists), the next step is **[Add to an existing app](asgi.md)**.
+When `run()` itself is the limit — your server inside an app that already exists — it is **[Add to an existing app](asgi.md)**. A real hostname and more than one worker is **[Deploy & scale](deploy.md)**. And if some of your clients are still on spec version 2025-11-25 or earlier, **[Serving legacy clients](legacy-clients.md)** is the good news.
diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md
new file mode 100644
index 000000000..9e09d4363
--- /dev/null
+++ b/docs/run/legacy-clients.md
@@ -0,0 +1,120 @@
+# Serving legacy clients
+
+MCP has two protocol eras: the `initialize`-handshake era, up to spec version `2025-11-25`, and the modern era, `2026-07-28`. **[Protocol versions](../protocol-versions.md)** is the page on the split itself.
+
+This page is about the server side of that split, and the answer fits in one sentence: **the `streamable_http_app()` you already deploy serves both.**
+
+The SDK routes every request by its `MCP-Protocol-Version` header. A request naming `2026-07-28` goes to the modern handler. A request naming a handshake-era version, or carrying no header at all (which is how a pre-2026 client's `initialize` arrives), goes to the transport those clients expect: `initialize` handshake, sessions and all. It happens per request, before your code, on the one app.
+
+So a legacy client is not something you build *for*. It is something that connects *to* the server you already wrote. You configure nothing.
+
+!!! note
+ Nothing, literally. There is no `legacy=` option, no version allowlist, no way to reject or
+ disable an era — not on `streamable_http_app()`, not on `run()`, not on the session manager.
+ Both eras are always on. The nearest thing to a per-era switch in that signature is
+ `stateless_http`, and it is most of this page.
+
+## One handler, both eras
+
+Here is a tool that has to ask the user something, and both eras of client calling it:
+
+```python title="server.py" hl_lines="24 37-38"
+--8<-- "docs_src/legacy_clients/tutorial001.py"
+```
+
+`reserve` needs one thing the model didn't supply: how many copies. `Annotated[..., Resolve(ask_quantity)]` is how a tool declares that (**[Dependencies](../handlers/dependencies.md)** is that whole story). Nothing in `reserve` names a version, checks a capability, or branches.
+
+The two clients are open **at the same time**, on the same `mcp` object. `mode="legacy"` runs the `initialize` handshake — the exact connection a pre-2026 client opens. The other one takes the default and lands on `2026-07-28`.
+
+```text
+2025-11-25 {'result': "Reserved 2 of 'Dune'."}
+2026-07-28 {'result': "Reserved 2 of 'Dune'."}
+```
+
+Same server, same handler, same answer. That is the whole feature.
+
+It is worth pausing on *how*, because the two clients were asked the same question over two completely different wires. The `2026-07-28` connection has no channel for the server to send a request on, so `Resolve` returned the question inside the tool result and the client retried the call with the answer (**[Multi-round-trip requests](../handlers/multi-round-trip.md)**). The `2025-11-25` connection has no such thing; there, `Resolve` sent a live `elicitation/create` request mid-call and waited. You wrote neither. `Resolve` reads the connection's negotiated version and picks; your tool body sees an `AcceptedElicitation` either way.
+
+!!! tip
+ That era-portability is *why* `Resolve` is the API to build on. Its older sibling `ctx.elicit()`
+ (**[Elicitation](../handlers/elicitation.md)**) only ever sends `elicitation/create`, so it only
+ ever works on a legacy connection — on a `2026-07-28` one the call fails. If a tool still uses
+ it, the fix is the one you see above, not a version check.
+
+## What a legacy session costs you
+
+The routing is free. The session is not.
+
+A `2026-07-28` connection is **sessionless**: every request stands alone, and the modern handler never issues an `Mcp-Session-Id`. A legacy connection is the opposite. The moment a pre-2026 client sends `initialize`, the SDK mints an `Mcp-Session-Id`, returns it in a response header, and keeps a live record behind it for the client's later requests to find: the negotiated version, the open streams, a background task driving the session.
+
+That record is a **plain in-process `dict`**. There is no distributed session store and no way to plug one in.
+
+On one worker that is invisible. On two, it is the whole problem: a request that carries an `Mcp-Session-Id` and lands on a worker that didn't mint it finds nothing in that dict, and the answer is a `404` — `Session not found` — not the tool result. So the moment you run more than one worker, **legacy clients need sticky routing**: every request in a session has to reach the process that started it. Modern clients never do; they have no session to be sticky to. **[Deploy & scale](deploy.md)** covers stickiness and everything else about running more than one of these.
+
+!!! warning
+ `event_store=` looks like the fix and is not. It is **resumability** — replaying missed SSE
+ events to a client reconnecting to the *same* session — not a session store. It never makes a
+ session reachable from another process.
+
+## The one knob: `stateless_http`
+
+If stickiness is a cost you refuse to pay, there is exactly one thing you can change.
+
+```python title="server.py" hl_lines="28"
+--8<-- "docs_src/legacy_clients/tutorial002.py"
+```
+
+That is the server from the top of the page plus one keyword. `stateless_http=True` makes the legacy leg build a throwaway, per-request session instead: no `Mcp-Session-Id` issued, nothing remembered between requests, so any worker can serve any request and the load balancer can do whatever it likes.
+
+Two things about it matter more than what it does.
+
+**It only touches the legacy leg.** Requests are routed on the version header *before* `stateless_http` is read, so the modern path never sees it. A `2026-07-28` connection is already sessionless and is exactly the same under either value.
+
+**It costs both server-to-client channels on that leg.** A session that lives for one `POST` has no stream for the server to push a request down and no standalone stream for it to push notifications down. Every server-initiated request raises `NoBackChannelError`: `ctx.elicit()`, the retired sampling and roots calls (**[Deprecated features](../deprecated.md)**), and — yes — `Resolve` asking a *legacy* client its question. Notifications don't even get an error; they are silently dropped.
+
+!!! check
+ Do the wrong thing. `reserve` is the exact tool that just served both clients. Deploy it with
+ `stateless_http=True`, connect the same two clients over HTTP, and call it from each.
+
+ The modern client still gets `Reserved 2 of 'Dune'.` — the modern leg didn't change.
+
+ The legacy client's call does not come back as an `is_error` result the model could read.
+ The whole request fails, as a top-level protocol error:
+
+ ```text
+ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
+ ```
+
+ `Resolve` did not save you. On a `2025-11-25` connection it *has* to send `elicitation/create`,
+ and the channel it needs is exactly the thing `stateless_http=True` gave away. Era-portable
+ code is not back-channel-free code.
+
+So it is a real trade, and it only exists on the legacy leg: **sessionful and sticky, or stateless and one-directional.** If your tools never call back into the client, `stateless_http=True` is free and you should take it. If they do, keep the sessions and keep the routing sticky.
+
+## Where your code actually forks
+
+Almost nowhere.
+
+Tools, resources, prompts, structured output, progress, errors: none of them care which era called. The `initialize` handshake, the `Mcp-Session-Id`, the standalone stream, the `DELETE` that ends a session — the SDK owns all of it, and a handler never sees any of it. Interactive input is *the* place the eras genuinely differ on the wire, and `Resolve` exists so that it is not your problem: you just watched one tool serve both.
+
+There is exactly one thing left, and it is **change notifications**, because the two eras listen on different pipes:
+
+* A `2026-07-28` client opens a `subscriptions/listen` stream and reads the subscriptions bus. `ctx.notify_resource_updated()` — and `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()` — publish there, and *only* there. **[Subscriptions](../handlers/subscriptions.md)** is that page.
+* A legacy client reads the standalone stream its session keeps open. `ctx.session.send_resource_updated()` — and `send_tool_list_changed()` and friends — write to the *connection* that carried the request: for a legacy session, that is its standalone stream. For a modern HTTP request there is no such channel, and the notification is quietly dropped.
+
+Over HTTP, neither call reaches the other era's clients. To tell everyone, call both:
+
+```python title="server.py" hl_lines="19-20"
+--8<-- "docs_src/legacy_clients/tutorial003.py"
+```
+
+Two lines, no `if`, no version check, and you are done. That is the entire list of things a handler does differently because a legacy client exists.
+
+## Recap
+
+* One `streamable_http_app()` serves both protocol eras. The SDK routes each request by its `MCP-Protocol-Version` header; there is nothing to configure and no era knob to look for.
+* A legacy client costs you a session: an in-process `Mcp-Session-Id` record with no distributed store behind it. More than one worker means **sticky routing**, or the wrong worker answers `404 Session not found`. **[Deploy & scale](deploy.md)** has the multi-worker story.
+* `stateless_http=True` is the one knob, and it is **legacy-leg-only**. It buys free load balancing for legacy clients at the price of both server-to-client channels on that leg: server-initiated requests raise `NoBackChannelError` (a top-level error at the client, not an `is_error` result), and notifications are dropped.
+* A `2026-07-28` connection is sessionless either way. `stateless_http` never touches it.
+* Your handler code forks on era in exactly one place: change notifications. `ctx.notify_*` reaches `subscriptions/listen` clients; `ctx.session.send_*` reaches legacy sessions. Call both.
+* Everything else — including asking the user for input, via `Resolve` — is era-portable by construction. Write the modern thing once.
diff --git a/docs/servers/handling-errors.md b/docs/servers/handling-errors.md
index b932150b9..16edcc920 100644
--- a/docs/servers/handling-errors.md
+++ b/docs/servers/handling-errors.md
@@ -130,3 +130,5 @@ It means a whole class of `raise` statements you don't write: don't re-validate
* `from mcp import MCPError`; the error-code constants come from `mcp_types`.
Errors handled. That is everything a server *exposes*. What every handler can read, and do back to the client while it runs, is the next section: **[Inside your handler](../handlers/index.md)**.
+
+The exact text of every error the SDK produces, what it means, and the one-move fix for each is **[Troubleshooting](../troubleshooting.md)**.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 000000000..c4ff6744e
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,412 @@
+# Troubleshooting
+
+Every heading on this page is the exact text of an error the SDK produces, followed by what it means and the one-move fix. Find the last line of your traceback (or your server log) here with your browser's find-in-page, and read only that entry.
+
+Several entries run against this one server. One tool and one templated resource, each raising for a city it doesn't know:
+
+```python title="server.py"
+--8<-- "docs_src/troubleshooting/tutorial001.py"
+```
+
+The errors this page quotes are real: the SDK's own test suite reproduces every one of them.
+
+## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)`
+
+This is not an MCP error. It is anyio noise, and your real error is the **last line** of the paste.
+
+`Client.__aenter__` starts a task group. anyio wraps anything that leaves a task group in an `ExceptionGroup`, so *every* exception that escapes an `async with Client(...)` block, whatever it is, arrives inside one:
+
+```python
+async def main() -> None:
+ async with Client(mcp) as client:
+ await client.read_resource("weather://Atlantis")
+```
+
+```text
+ + Exception Group Traceback (most recent call last):
+ | ...
+ | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+ +-+---------------- 1 ----------------
+ | Exception Group Traceback (most recent call last):
+ | ...
+ | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
+ +-+---------------- 1 ----------------
+ | Traceback (most recent call last):
+ | ...
+ | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'.
+ +------------------------------------
+```
+
+Two things to do with that:
+
+1. **Read the bottom.** `MCPError: No forecast for 'Atlantis'.` is the failure; find *its* text on this page.
+2. **Catch inside the block.** The `ExceptionGroup` only appears when the exception *leaves* the `async with`. Caught inside it, the same failure is the plain `MCPError`, no group anywhere:
+
+```python
+async def main() -> None:
+ async with Client(mcp) as client:
+ try:
+ await client.read_resource("weather://Atlantis")
+ except MCPError as e:
+ print(e) # No forecast for 'Atlantis'.
+```
+
+!!! tip
+ A failure during *connection* (a wrong URL, a server that isn't running, the `421` further
+ down this page) escapes from `async with` itself, so there is no "inside" to catch it in.
+ For those, read the bottom of the group.
+
+## `RuntimeError: Client must be used within an async context manager`
+
+`Client(...)` only builds the object. Nothing connects until `async with`, so every method refuses:
+
+```python
+async def main() -> None:
+ client = Client(mcp)
+ tools = await client.list_tools() # RuntimeError
+```
+
+Enter it. `__aenter__` is the connection:
+
+```python
+async def main() -> None:
+ async with Client(mcp) as client:
+ tools = await client.list_tools()
+```
+
+`__aexit__` is the disconnection, which is why there is no `client.close()` to forget. **[Testing](get-started/testing.md)** is built on exactly this pattern.
+
+## `Error executing tool : ` and `Unknown tool: `
+
+You are reading a **result**, not an exception. `call_tool` did not raise, and it never will for a failing tool.
+
+Call `forecast` for a city the server doesn't know, and the exception it raises comes back with the request marked as *succeeded*:
+
+```python
+result.is_error # True
+result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")]
+result.structured_content # None
+```
+
+`Unknown tool: get_forecast` is the same shape for a name the server never registered, and a bad argument is rejected the same way, against the tool's input schema, before your function ever runs.
+
+The fix is in your client: **check `result.is_error`**. A `try/except` around `call_tool` catches none of these, because there is nothing to catch. This is deliberate, and it is the single most useful thing on this page to internalise: the *model* chose the call, so the model gets the message and a chance to try again. **[Handling errors](servers/handling-errors.md)** is the whole story, including the `MCPError` path that *does* raise.
+
+## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool`
+
+You wrote `@mcp.tool` instead of `@mcp.tool()`. `tool()` is a decorator *factory*: without the parentheses, Python hands your function to its `name=` parameter.
+
+```python
+@mcp.tool # <- missing ()
+def forecast(city: str) -> str:
+ """Today's forecast for one city."""
+ return f"{city}: Rain."
+```
+
+```text
+TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool
+```
+
+Add the parentheses. `@mcp.resource(...)` and `@mcp.prompt()` say the same thing for the same slip.
+
+!!! note
+ This raises when the module is **imported**, before any client connects. So a host that shows
+ your server as *failed to start* (or *disconnected*), rather than as connected with zero
+ tools, has this shape: run `python server.py` yourself and read the traceback. A type checker
+ also catches it: a function is not a valid `name=`.
+
+## `Tool already exists: `
+
+Two registrations used the same tool name. The **first** one wins, the second is silently dropped, and this warning in the *server log* is the only signal:
+
+```python title="server.py" hl_lines="6 12"
+--8<-- "docs_src/troubleshooting/tutorial002.py"
+```
+
+```text
+WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast
+```
+
+`tools/list` reports one `forecast`, and it is `forecast_today`. Rename one of them. `MCPServer(..., warn_on_duplicate_tools=False)` silences the warning without changing the outcome, so leave it on. Resources and prompts have the same rule and the same log line (`Resource already exists:`, `Prompt already exists:`).
+
+## My host lists zero tools
+
+There is no error string for this, which is exactly why it is hard to search. The SDK never drops a registered tool from `tools/list`, so work outward:
+
+* **Did the server start at all?** `@mcp.tool` without parentheses raises at import time, and a crashed server looks a lot like an empty one in some hosts. Run `python server.py` yourself.
+* **Is the tool on the `mcp` the host is running?** A second `MCPServer(...)` in another module is a different, empty server. Check which object the host's command actually imports.
+* **Did two tools share a name?** Then one of them is gone. Look for `Tool already exists:` in the server log.
+* **Is the host's list stale?** Adding a tool after startup only reaches clients that handle `notifications/tools/list_changed`. Restarting the host is the blunt fix.
+* **Did something write to `stdout`?** On a stdio transport, stdout *is* the protocol: one stray `print()` and the host drops the connection, which some hosts render as a server with nothing in it. Log with the `logging` module instead. The rest of the host-side checklist is on **[Connect to a real host](get-started/real-host.md)**.
+
+An "invalid" tool name is *not* on that list: a non-conforming name logs a warning but the tool is registered and listed anyway.
+
+## `MCPError: Server returned an error response`
+
+The server refused the HTTP request outright, with a body that is not JSON-RPC, so the python `Client` has nothing better to show you than this stand-in.
+
+By far the most common cause is a freshly deployed Streamable HTTP server. `streamable_http_app()` (and `mcp.run("streamable-http")`) with no `transport_security=` defaults to **DNS-rebinding protection**: it accepts only requests whose `Host` header is localhost. That is the right default on your laptop and the wrong one behind a real hostname:
+
+```python title="server.py" hl_lines="12"
+--8<-- "docs_src/troubleshooting/tutorial003.py"
+```
+
+Deploy that, point a client at it, and the connection fails on the handshake:
+
+```python
+async with Client("https://mcp.example.com/mcp") as client:
+ ...
+```
+
+```text
+mcp.shared.exceptions.MCPError: Server returned an error response
+```
+
+The words the server actually sent, `421` and `Invalid Host header`, never reach you: the 421 body has no `Content-Type: application/json`, so the client cannot parse it. They are in the **server's log**, which is where to look next:
+
+```text
+WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com
+```
+
+The fix is `transport_security=`. Allowlist the hostname you actually serve:
+
+```python title="server.py" hl_lines="14-17"
+--8<-- "docs_src/troubleshooting/tutorial004.py"
+```
+
+!!! check
+ That is the whole change. The identical client now connects, negotiates `2026-07-28`, and
+ calls `forecast`.
+
+**[Deploy & scale](run/deploy.md)** covers what each field means, the reverse-proxy case, and everything else that changes at deploy time. And `421 Misdirected Request` / `Invalid Host header`, right below, is the same failure seen from the other side.
+
+## `421 Misdirected Request` / `Invalid Host header`
+
+This is `Server returned an error response`, seen from anything that is *not* the python `Client`: curl, a browser's network tab, a reverse proxy's access log, or another SDK.
+
+```bash
+curl -i https://mcp.example.com/mcp \
+ -H 'Content-Type: application/json' \
+ -H 'Accept: application/json, text/event-stream' \
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
+```
+
+```text
+HTTP/1.1 421 Misdirected Request
+
+Invalid Host header
+```
+
+`421 Misdirected Request` is HTTP's own reason phrase for the status; `Invalid Host header` is the SDK's response body; and the python `Client` renders the same event as `Server returned an error response`. All three are one refusal. The check runs against the **`Host` header the request carries**, not the address the server bound, so a reverse proxy that forwards the public hostname trips it exactly as a direct client does.
+
+The fix is the same `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` shown under `Server returned an error response`. Two of its edges are worth naming:
+
+* An `allowed_hosts` entry is an exact string. `"mcp.example.com"` matches a bare `Host` header and `"mcp.example.com:*"` matches any explicit port. List both.
+* A `403` with the body `Invalid Origin header` is the sibling check on the `Origin` header. It only fires for browsers (nothing else sends `Origin`), and `allowed_origins=` is its allowlist.
+
+**[Deploy & scale](run/deploy.md)** has the full treatment, including when switching the check off is the honest configuration.
+
+## `RuntimeError: Task group is not initialized. Make sure to use run().`
+
+Your MCP app is mounted inside another ASGI app, and nothing started its **session manager**.
+
+`mcp.streamable_http_app()` returns a Starlette app whose own lifespan starts the manager, and `uvicorn server:app` runs that lifespan for you. But Starlette **never runs a mounted sub-application's lifespan**, so the moment the app goes inside a `Mount`, the manager never starts and the first request explodes:
+
+```python title="server.py" hl_lines="16"
+--8<-- "docs_src/troubleshooting/tutorial005.py"
+```
+
+The server starts. The route resolves. Then `uvicorn` prints this for every request:
+
+```text
+ERROR: Exception in ASGI application
+Traceback (most recent call last):
+ ...
+RuntimeError: Task group is not initialized. Make sure to use run().
+```
+
+The client sees a 500. The fix is a lifespan on the **host** app that enters `mcp.session_manager.run()`:
+
+```python
+@asynccontextmanager
+async def lifespan(app: Starlette) -> AsyncIterator[None]:
+ async with mcp.session_manager.run():
+ yield
+
+
+app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan)
+```
+
+**[Add to an existing app](run/asgi.md)** is the page for this, including several servers in one app and FastAPI. Two neighbouring strings from the same class:
+
+* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` The manager is single-use; entering the same app's lifespan twice hits it.
+* `mcp.session_manager` only exists **after** `streamable_http_app()` has been called, so build the routes first and touch the manager only inside the lifespan.
+
+## `MCPError: Session not found`
+
+The server does not recognise the `Mcp-Session-Id` your client sent, almost always because the server **restarted** (or you were routed to a different instance). Sessions live in that one process's memory.
+
+There is no server bug to find. The HTTP response is a `404` whose body *is* JSON-RPC, so, unlike the `421` above, the python `Client` shows you this one verbatim:
+
+```json
+{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}}
+```
+
+The fix is to reconnect: leave the `async with Client(...)` block and enter a new one, which negotiates a fresh session. For a long-lived client, that means catching `MCPError` around your calls and reconnecting on this message rather than retrying inside a dead session.
+
+If it happens *without* a restart, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`).
+
+For the server operator, the matching log line is `Rejected request with unknown or expired session ID: ` — logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting.
+
+## `MCPError: Method not found`
+
+One side sent a JSON-RPC request the other side has no handler for, and `e.error.data` names the method. The one everybody hits has `data` equal to `elicitation/create`:
+
+```python title="server.py" hl_lines="16"
+--8<-- "docs_src/troubleshooting/tutorial006.py"
+```
+
+```python
+async def main() -> None:
+ async with Client(mcp) as client:
+ await client.call_tool("book_table", {"date": "Friday"})
+```
+
+```text
+mcp.shared.exceptions.MCPError: Method not found
+```
+
+`ctx.elicit()` (and `ctx.elicit_url()`) is a request from the *server* to the *client*, and the **2026-07-28** protocol has no server-initiated requests. The in-memory `Client(server)` negotiates `2026-07-28` without being asked, so this fails on the very first test, and passing `elicitation_callback=` changes nothing: the method itself is what's missing, not the handler.
+
+The fix is to move the question out of the tool body and into a **resolver**, which works on every protocol version:
+
+```python title="server.py" hl_lines="15-17 21"
+--8<-- "docs_src/troubleshooting/tutorial007.py"
+```
+
+Same question, same `elicitation_callback` on the client. The difference is under the hood: a resolver lets the server *return* the question from the call instead of pushing it, so nothing ever flows server-to-client. **[Elicitation](handlers/elicitation.md)** covers resolvers; **[Multi-round-trip requests](handlers/multi-round-trip.md)** covers what actually happens on the wire.
+
+!!! check
+ The tool with `ctx.elicit()` is not wrong, it is *pre-2026*. Connect with `mode="legacy"`
+ (the classic `initialize` handshake, spec `2025-11-25` and earlier) and it works, because the
+ server-to-client channel exists there. **[Protocol versions](protocol-versions.md)** is the
+ page on what each version has.
+
+## `MCPError: Client did not declare the form elicitation capability required by resolver ''`
+
+Your server wants to ask the user something, and this client never said it can be asked.
+
+An elicitation resolver refuses up front when the connected client did not declare form elicitation, and `e.error.data` names exactly what is missing:
+
+```json
+{
+ "code": -32021,
+ "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'",
+ "data": {"requiredCapabilities": {"elicitation": {"form": {}}}}
+}
+```
+
+Pass `elicitation_callback=` to `Client(...)`. Registering the callback *is* the capability declaration; there is no second switch:
+
+```python
+async def main() -> None:
+ async with Client(mcp, elicitation_callback=handle_elicitation) as client:
+ result = await client.call_tool("book_table", {"date": "Friday"})
+```
+
+**[Client callbacks](client/callbacks.md)** lists the others (`sampling_callback`, `list_roots_callback`), each of which is a declaration in the same way.
+
+!!! info
+ `-32021` is `MISSING_REQUIRED_CLIENT_CAPABILITY`, one of three error codes the 2026-07-28
+ spec adds. None of them is an exception class: they all arrive as `MCPError`, and
+ `e.error.code` is where to look. `mcp_types` exports the constants. The other two are
+ `-32020` `HEADER_MISMATCH` (an HTTP header disagrees with the request body it accompanies)
+ and `-32022` `UNSUPPORTED_PROTOCOL_VERSION` (the request named a version this server does not
+ speak). A conforming SDK client cannot produce either, so if you see one, look at whatever is
+ rewriting requests between your client and your server.
+
+## `MCPError: Elicitation not supported`
+
+The same gap as `Client did not declare the form elicitation capability ...`, spelled by the paths that don't check up front: the server needed an elicitation answered, and the connected client registered no `elicitation_callback`.
+
+You see this one from `ctx.elicit()` on a legacy connection, and — on any connection — from a returned multi-round-trip question (**[Multi-round-trip requests](handlers/multi-round-trip.md)**) that reaches a client with no callback to answer it. The fix is identical: pass `elicitation_callback=` to `Client(...)`. There is no version of "the user wasn't asked" that your tool receives as a `decline`; a client that cannot be asked is a failed call, so design your tools for it.
+
+## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.`
+
+Your handler tried to reach the client mid-request, on a transport where nothing can.
+
+Stateless HTTP is the usual trigger. `stateless_http=True` means every request is its own world: no session, no server-to-client stream, and so nowhere to send an `elicitation/create` (or `sampling/createMessage`, or `roots/list`):
+
+```python title="server.py" hl_lines="16 23"
+--8<-- "docs_src/troubleshooting/tutorial008.py"
+```
+
+The message names the method it could not send. `NoBackChannelError` is the class the server raises, but the wire carries only the base `MCPError`, so the sentence above is your traceback's last line, not the class name.
+
+The fix is the same as for `Method not found`: don't reach back mid-call. A **resolver** (or a returned `InputRequiredResult`) turns the question into part of the *response*, which every transport can carry, stateless or not. **[Multi-round-trip requests](handlers/multi-round-trip.md)** is that mechanism.
+
+## `MCPError: Invalid or expired requestState`
+
+The server could not verify the `requestState` token your client echoed back, so it refused the round.
+
+`requestState` is the opaque resume token a **[multi-round-trip](handlers/multi-round-trip.md)** call carries between legs. `MCPServer` seals it on the way out and verifies every echo, and it verifies *every* inbound `request_state` on `tools/call`, `prompts/get`, and `resources/read`, even for a handler that never mints one. So a token this process didn't seal is refused wherever it lands:
+
+```python
+async def main() -> None:
+ async with Client(mcp) as client:
+ await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a")
+```
+
+```text
+mcp.shared.exceptions.MCPError: Invalid or expired requestState
+```
+
+The message is deliberately frozen: the wire never reveals which check failed. The reason goes to the **server log**, and reading it is the whole diagnosis:
+
+```text
+WARNING mcp.server.request_state: requestState rejected on tools/call: malformed
+```
+
+The reasons you will actually see:
+
+* **`unknown key`** is the one that matters. The default sealing key is generated at process start, so a retry that lands on a **different worker**, a different instance behind a load balancer, or the same server **after a restart** was sealed under a key this process never had. That is not an attacker; it is the default meeting more than one process.
+* **`audience`**: the token was sealed by an instance with a *different server name*. The name is the seal's default audience claim, so a fleet must share the name — or set an explicit `RequestStateSecurity(audience=...)` — as well as the keys.
+* **`expired`**: the round took longer than the seal's `ttl`, which is 600 seconds and per round, not per call.
+* **`malformed`** / **`codec error`**: the token was altered in transit, or was never a sealed token at all.
+* **`request binding`**: the token came back with a different tool, different arguments, or a different method.
+
+The multi-process fix is one argument — the *same* `keys` on every instance — plus one thing that is not an argument at all: the same server *name* (or an explicit shared `audience=`).
+
+```python
+mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key]))
+```
+
+`keys[0]` seals; every key in the list verifies, which is what makes zero-downtime rotation possible. **[Multi-round-trip requests](handlers/multi-round-trip.md#protecting-requeststate)** explains what the seal protects and the rotation sequence, and **[Deploy & scale](run/deploy.md)** walks the whole two-worker failure and its two-part fix.
+
+!!! tip
+ `keys=[...]` refuses a weak key immediately, with an unusually helpful message:
+
+ ```text
+ ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))"
+ ```
+
+ Do what it says.
+
+## Still stuck?
+
+* If a message the SDK produced is not on this page, that is a documentation bug worth reporting on its own.
+* Search the [issue tracker](https://github.com/modelcontextprotocol/python-sdk/issues); most error strings appearing there are already someone's write-up.
+* Found nothing? [Open an issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) with the full traceback, or ask in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX).
+
+## Recap
+
+* `ExceptionGroup: unhandled errors in a TaskGroup` is never the error. Read the **last line**; catching `MCPError` *inside* the `async with Client(...)` block skips the wrapping entirely.
+* `call_tool` does not raise for a failing tool. `Error executing tool ...` and `Unknown tool: ...` are results: check `result.is_error`.
+* `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses.
+* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one.
+* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: ` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`.
+* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.
+* `Session not found` -> the server restarted; reconnect.
+* `Method not found` on `elicitation/create` -> `ctx.elicit()` needs a server-to-client channel and `2026-07-28` has none. Use a resolver.
+* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
+* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.
diff --git a/docs_src/deploy/__init__.py b/docs_src/deploy/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/docs_src/deploy/tutorial001.py b/docs_src/deploy/tutorial001.py
new file mode 100644
index 000000000..7b0025936
--- /dev/null
+++ b/docs_src/deploy/tutorial001.py
@@ -0,0 +1,17 @@
+from mcp.server import MCPServer
+from mcp.server.transport_security import TransportSecuritySettings
+
+mcp = MCPServer("Notes")
+
+
+@mcp.tool()
+def add_note(text: str) -> str:
+ """Save a note."""
+ return f"Saved: {text}"
+
+
+security = TransportSecuritySettings(
+ allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
+ allowed_origins=["https://app.example.com"],
+)
+app = mcp.streamable_http_app(transport_security=security)
diff --git a/docs_src/deploy/tutorial002.py b/docs_src/deploy/tutorial002.py
new file mode 100644
index 000000000..8b61aacac
--- /dev/null
+++ b/docs_src/deploy/tutorial002.py
@@ -0,0 +1,27 @@
+from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
+
+from mcp.server.mcpserver import Context, MCPServer
+
+CONFIRM = ElicitRequest(
+ params=ElicitRequestFormParams(
+ message="Issue this refund?",
+ requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
+ )
+)
+
+
+def make_server() -> MCPServer:
+ """Every worker process builds one of these, once, at import."""
+ mcp = MCPServer("billing")
+
+ @mcp.tool()
+ async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
+ """Refund an amount, once a human has confirmed it."""
+ if ctx.input_responses is None:
+ return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}")
+ answer = (ctx.input_responses or {}).get("ok")
+ if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"):
+ return "refund cancelled"
+ return f"refunded ${amount}"
+
+ return mcp
diff --git a/docs_src/deploy/tutorial003.py b/docs_src/deploy/tutorial003.py
new file mode 100644
index 000000000..8d9d126c0
--- /dev/null
+++ b/docs_src/deploy/tutorial003.py
@@ -0,0 +1,27 @@
+from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult
+
+from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
+
+CONFIRM = ElicitRequest(
+ params=ElicitRequestFormParams(
+ message="Issue this refund?",
+ requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
+ )
+)
+
+
+def make_server(key: str) -> MCPServer:
+ """Every worker process: the same key, and the same name."""
+ mcp = MCPServer("billing", request_state_security=RequestStateSecurity(keys=[key]))
+
+ @mcp.tool()
+ async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
+ """Refund an amount, once a human has confirmed it."""
+ if ctx.input_responses is None:
+ return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}")
+ answer = (ctx.input_responses or {}).get("ok")
+ if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"):
+ return "refund cancelled"
+ return f"refunded ${amount}"
+
+ return mcp
diff --git a/docs_src/deploy/tutorial004.py b/docs_src/deploy/tutorial004.py
new file mode 100644
index 000000000..5f32c65bb
--- /dev/null
+++ b/docs_src/deploy/tutorial004.py
@@ -0,0 +1,23 @@
+from mcp.server.mcpserver import Context, MCPServer
+from mcp.server.subscriptions import SubscriptionBus
+
+NOTES = {"todo": "buy milk"}
+
+
+def make_server(bus: SubscriptionBus) -> MCPServer:
+ """Every replica gets its own server object; all of them hold the same bus."""
+ mcp = MCPServer("Notebook", subscriptions=bus)
+
+ @mcp.resource("note://{name}")
+ def note(name: str) -> str:
+ """One note, by name."""
+ return NOTES[name]
+
+ @mcp.tool()
+ async def edit_note(name: str, text: str, ctx: Context) -> str:
+ """Replace a note's text."""
+ NOTES[name] = text
+ await ctx.notify_resource_updated(f"note://{name}")
+ return "saved"
+
+ return mcp
diff --git a/docs_src/legacy_clients/__init__.py b/docs_src/legacy_clients/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/docs_src/legacy_clients/tutorial001.py b/docs_src/legacy_clients/tutorial001.py
new file mode 100644
index 000000000..2f8b1191e
--- /dev/null
+++ b/docs_src/legacy_clients/tutorial001.py
@@ -0,0 +1,42 @@
+from typing import Annotated
+
+from mcp_types import ElicitRequestParams, ElicitResult
+from pydantic import BaseModel
+
+from mcp import Client
+from mcp.client import ClientRequestContext
+from mcp.server import MCPServer
+from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
+
+mcp = MCPServer("Bookshop")
+
+
+class Quantity(BaseModel):
+ copies: int
+
+
+async def ask_quantity() -> Elicit[Quantity]:
+ """Resolver: ask the user how many copies to put aside."""
+ return Elicit("How many copies?", Quantity)
+
+
+@mcp.tool()
+async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
+ """Reserve copies of a book, asking the user how many."""
+ if isinstance(quantity, AcceptedElicitation):
+ return f"Reserved {quantity.data.copies} of {title!r}."
+ return "Nothing reserved."
+
+
+async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
+ return ElicitResult(action="accept", content={"copies": 2})
+
+
+async def main() -> None:
+ async with (
+ Client(mcp, mode="legacy", elicitation_callback=answer) as legacy,
+ Client(mcp, elicitation_callback=answer) as modern,
+ ):
+ for client in (legacy, modern):
+ result = await client.call_tool("reserve", {"title": "Dune"})
+ print(client.protocol_version, result.structured_content)
diff --git a/docs_src/legacy_clients/tutorial002.py b/docs_src/legacy_clients/tutorial002.py
new file mode 100644
index 000000000..53c6475a4
--- /dev/null
+++ b/docs_src/legacy_clients/tutorial002.py
@@ -0,0 +1,28 @@
+from typing import Annotated
+
+from pydantic import BaseModel
+
+from mcp.server import MCPServer
+from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve
+
+mcp = MCPServer("Bookshop")
+
+
+class Quantity(BaseModel):
+ copies: int
+
+
+async def ask_quantity() -> Elicit[Quantity]:
+ """Resolver: ask the user how many copies to put aside."""
+ return Elicit("How many copies?", Quantity)
+
+
+@mcp.tool()
+async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
+ """Reserve copies of a book, asking the user how many."""
+ if isinstance(quantity, AcceptedElicitation):
+ return f"Reserved {quantity.data.copies} of {title!r}."
+ return "Nothing reserved."
+
+
+app = mcp.streamable_http_app(stateless_http=True)
diff --git a/docs_src/legacy_clients/tutorial003.py b/docs_src/legacy_clients/tutorial003.py
new file mode 100644
index 000000000..52f8f1ea6
--- /dev/null
+++ b/docs_src/legacy_clients/tutorial003.py
@@ -0,0 +1,21 @@
+from mcp.server import MCPServer
+from mcp.server.mcpserver import Context
+
+mcp = MCPServer("Bookshop")
+
+STOCK = {"Dune": 3}
+
+
+@mcp.resource("stock://{title}")
+def stock(title: str) -> str:
+ """How many copies of one book are on the shelf."""
+ return f"{STOCK[title]} in stock"
+
+
+@mcp.tool()
+async def restock(title: str, copies: int, ctx: Context) -> str:
+ """Put copies of a book back on the shelf."""
+ STOCK[title] = STOCK.get(title, 0) + copies
+ await ctx.notify_resource_updated(f"stock://{title}")
+ await ctx.session.send_resource_updated(f"stock://{title}")
+ return f"{STOCK[title]} in stock"
diff --git a/docs_src/real_host/__init__.py b/docs_src/real_host/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/docs_src/real_host/tutorial001.py b/docs_src/real_host/tutorial001.py
new file mode 100644
index 000000000..1cd39c8c5
--- /dev/null
+++ b/docs_src/real_host/tutorial001.py
@@ -0,0 +1,34 @@
+from mcp.server import MCPServer
+
+mcp = MCPServer("Bookshop")
+
+CATALOG = {
+ "Dune": "Frank Herbert",
+ "Neuromancer": "William Gibson",
+ "The Left Hand of Darkness": "Ursula K. Le Guin",
+}
+
+
+@mcp.tool()
+def search_books(query: str) -> list[str]:
+ """Search the catalog by title or author."""
+ needle = query.lower()
+ return [title for title, author in CATALOG.items() if needle in title.lower() or needle in author.lower()]
+
+
+@mcp.tool()
+def get_author(title: str) -> str:
+ """Look up the author of a book in the catalog."""
+ if title not in CATALOG:
+ raise ValueError(f"No book titled {title!r} in the catalog.")
+ return CATALOG[title]
+
+
+@mcp.resource("catalog://titles")
+def titles() -> str:
+ """Every title in the catalog, one per line."""
+ return "\n".join(sorted(CATALOG))
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/docs_src/troubleshooting/__init__.py b/docs_src/troubleshooting/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/docs_src/troubleshooting/tutorial001.py b/docs_src/troubleshooting/tutorial001.py
new file mode 100644
index 000000000..e83a552df
--- /dev/null
+++ b/docs_src/troubleshooting/tutorial001.py
@@ -0,0 +1,22 @@
+from mcp.server import MCPServer
+from mcp.server.mcpserver.exceptions import ResourceNotFoundError
+
+mcp = MCPServer("Weather")
+
+FORECASTS = {"London": "Rain.", "Cairo": "Sun."}
+
+
+@mcp.tool()
+def forecast(city: str) -> str:
+ """Today's forecast for one city."""
+ if city not in FORECASTS:
+ raise ValueError(f"No forecast for {city!r}.")
+ return FORECASTS[city]
+
+
+@mcp.resource("weather://{city}")
+def report(city: str) -> str:
+ """The full report for one city."""
+ if city not in FORECASTS:
+ raise ResourceNotFoundError(f"No forecast for {city!r}.")
+ return f"{city}: {FORECASTS[city]}"
diff --git a/docs_src/troubleshooting/tutorial002.py b/docs_src/troubleshooting/tutorial002.py
new file mode 100644
index 000000000..ec6872567
--- /dev/null
+++ b/docs_src/troubleshooting/tutorial002.py
@@ -0,0 +1,15 @@
+from mcp.server import MCPServer
+
+mcp = MCPServer("Weather")
+
+
+@mcp.tool(name="forecast")
+def forecast_today(city: str) -> str:
+ """Today's forecast for one city."""
+ return f"{city}: Rain."
+
+
+@mcp.tool(name="forecast") # Same name. This registration is dropped.
+def forecast_hourly(city: str, hours: int) -> str:
+ """The next few hours for one city."""
+ return f"{city}: Rain for {hours}h."
diff --git a/docs_src/troubleshooting/tutorial003.py b/docs_src/troubleshooting/tutorial003.py
new file mode 100644
index 000000000..e2e07f688
--- /dev/null
+++ b/docs_src/troubleshooting/tutorial003.py
@@ -0,0 +1,12 @@
+from mcp.server import MCPServer
+
+mcp = MCPServer("Weather")
+
+
+@mcp.tool()
+def forecast(city: str) -> str:
+ """Today's forecast for one city."""
+ return f"{city}: Rain."
+
+
+app = mcp.streamable_http_app()
diff --git a/docs_src/troubleshooting/tutorial004.py b/docs_src/troubleshooting/tutorial004.py
new file mode 100644
index 000000000..b78fa1d94
--- /dev/null
+++ b/docs_src/troubleshooting/tutorial004.py
@@ -0,0 +1,18 @@
+from mcp.server import MCPServer
+from mcp.server.transport_security import TransportSecuritySettings
+
+mcp = MCPServer("Weather")
+
+
+@mcp.tool()
+def forecast(city: str) -> str:
+ """Today's forecast for one city."""
+ return f"{city}: Rain."
+
+
+app = mcp.streamable_http_app(
+ transport_security=TransportSecuritySettings(
+ allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
+ allowed_origins=["https://app.example.com"],
+ )
+)
diff --git a/docs_src/troubleshooting/tutorial005.py b/docs_src/troubleshooting/tutorial005.py
new file mode 100644
index 000000000..ca990da7d
--- /dev/null
+++ b/docs_src/troubleshooting/tutorial005.py
@@ -0,0 +1,16 @@
+from starlette.applications import Starlette
+from starlette.routing import Mount
+
+from mcp.server import MCPServer
+
+mcp = MCPServer("Weather")
+
+
+@mcp.tool()
+def forecast(city: str) -> str:
+ """Today's forecast for one city."""
+ return f"{city}: Rain."
+
+
+# The mount works. The MCP app's own lifespan never runs.
+app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())])
diff --git a/docs_src/troubleshooting/tutorial006.py b/docs_src/troubleshooting/tutorial006.py
new file mode 100644
index 000000000..2c995f56a
--- /dev/null
+++ b/docs_src/troubleshooting/tutorial006.py
@@ -0,0 +1,19 @@
+from pydantic import BaseModel
+
+from mcp.server import MCPServer
+from mcp.server.mcpserver import Context
+
+mcp = MCPServer("Bistro")
+
+
+class Confirmation(BaseModel):
+ confirm: bool
+
+
+@mcp.tool()
+async def book_table(date: str, ctx: Context) -> str:
+ """Book a table at the bistro."""
+ result = await ctx.elicit(f"Book a table for {date}?", schema=Confirmation)
+ if result.action == "accept" and result.data.confirm:
+ return f"Booked for {date}."
+ return "No booking made."
diff --git a/docs_src/troubleshooting/tutorial007.py b/docs_src/troubleshooting/tutorial007.py
new file mode 100644
index 000000000..051c29c05
--- /dev/null
+++ b/docs_src/troubleshooting/tutorial007.py
@@ -0,0 +1,25 @@
+from typing import Annotated
+
+from pydantic import BaseModel
+
+from mcp.server import MCPServer
+from mcp.server.mcpserver import Elicit, Resolve
+
+mcp = MCPServer("Bistro")
+
+
+class Confirmation(BaseModel):
+ confirm: bool
+
+
+async def ask_to_confirm(date: str) -> Elicit[Confirmation]:
+ """Resolver: ask the user to confirm the booking."""
+ return Elicit(f"Book a table for {date}?", Confirmation)
+
+
+@mcp.tool()
+async def book_table(date: str, answer: Annotated[Confirmation, Resolve(ask_to_confirm)]) -> str:
+ """Book a table at the bistro."""
+ if answer.confirm:
+ return f"Booked for {date}."
+ return "No booking made."
diff --git a/docs_src/troubleshooting/tutorial008.py b/docs_src/troubleshooting/tutorial008.py
new file mode 100644
index 000000000..1779cffd3
--- /dev/null
+++ b/docs_src/troubleshooting/tutorial008.py
@@ -0,0 +1,23 @@
+from pydantic import BaseModel
+
+from mcp.server import MCPServer
+from mcp.server.mcpserver import Context
+
+mcp = MCPServer("Bistro")
+
+
+class Confirmation(BaseModel):
+ confirm: bool
+
+
+@mcp.tool()
+async def book_table(date: str, ctx: Context) -> str:
+ """Book a table at the bistro."""
+ result = await ctx.elicit(f"Book a table for {date}?", schema=Confirmation)
+ if result.action == "accept" and result.data.confirm:
+ return f"Booked for {date}."
+ return "No booking made."
+
+
+# Stateless HTTP: every request is its own world. No channel back to the client.
+app = mcp.streamable_http_app(stateless_http=True)
diff --git a/mkdocs.yml b/mkdocs.yml
index d7ed35487..1f2192fb6 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -16,6 +16,7 @@ nav:
- get-started/index.md
- Installation: get-started/installation.md
- First steps: get-started/first-steps.md
+ - Connect to a real host: get-started/real-host.md
- Testing: get-started/testing.md
- Servers:
- servers/index.md
@@ -40,8 +41,10 @@ nav:
- Running your server:
- run/index.md
- Add to an existing app: run/asgi.md
+ - Deploy & scale: run/deploy.md
- Authorization: run/authorization.md
- OpenTelemetry: run/opentelemetry.md
+ - Serving legacy clients: run/legacy-clients.md
- Clients:
- client/index.md
- Callbacks: client/callbacks.md
@@ -59,6 +62,7 @@ nav:
- Middleware: advanced/middleware.md
- Extensions: advanced/extensions.md
- MCP Apps: advanced/apps.md
+ - Troubleshooting: troubleshooting.md
- Migration Guide: migration.md
- API Reference: api/
diff --git a/tests/docs_src/test_deploy.py b/tests/docs_src/test_deploy.py
new file mode 100644
index 000000000..64ea65c95
--- /dev/null
+++ b/tests/docs_src/test_deploy.py
@@ -0,0 +1,226 @@
+"""`docs/run/deploy.md`: every claim the page makes, proved against the real SDK."""
+
+import anyio
+import httpx
+import pytest
+from mcp_types import (
+ INVALID_PARAMS,
+ CallToolResult,
+ ElicitResult,
+ InputRequiredResult,
+ ResourceUpdatedNotification,
+ SubscriptionFilter,
+ SubscriptionsListenRequest,
+ SubscriptionsListenRequestParams,
+ SubscriptionsListenResult,
+ TextContent,
+)
+
+from docs_src.deploy import tutorial001, tutorial002, tutorial003, tutorial004
+from mcp import Client, MCPError
+from mcp.server import MCPServer
+from mcp.server.mcpserver import Context, RequestStateSecurity
+from mcp.server.subscriptions import InMemorySubscriptionBus
+
+# See test_index.py for why this is a per-module mark and not a conftest hook.
+pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
+
+_KEY = "0123456789abcdef0123456789abcdef" # 32 bytes: the smallest secret the SDK accepts.
+
+INITIALIZE = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
+}
+MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
+
+
+# -- the Host allowlist ----------------------------------------------------------------
+
+
+async def test_the_default_app_rejects_a_real_hostname_before_mcp_runs() -> None:
+ """The section's `!!! check`: without `transport_security=`, a deployed hostname gets the page's exact 421."""
+ bare = MCPServer("Notes")
+ app = bare.streamable_http_app()
+ async with bare.session_manager.run():
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as h:
+ response = await h.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
+ assert (response.status_code, response.text) == (421, "Invalid Host header")
+
+
+async def test_the_allowlisted_app_serves_its_hostname_and_still_rejects_others() -> None:
+ """tutorial001: `allowed_hosts=` opens exactly the hostname you named, and nothing else."""
+ transport = httpx.ASGITransport(app=tutorial001.app)
+ async with tutorial001.mcp.session_manager.run():
+ async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
+ allowed = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
+ async with httpx.AsyncClient(transport=transport, base_url="https://api.example.com") as http:
+ rejected = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
+ assert allowed.status_code == 200
+ assert allowed.headers["mcp-session-id"]
+ assert (rejected.status_code, rejected.text) == (421, "Invalid Host header")
+
+
+# -- `requestState` across workers -----------------------------------------------------
+
+
+async def _first_round(client: Client, amount: int) -> str:
+ """Round one of `refund`: no answers yet, so the server returns the `InputRequiredResult`."""
+ first = await client.session.call_tool("refund", {"amount": amount}, allow_input_required=True)
+ assert isinstance(first, InputRequiredResult)
+ assert first.request_state is not None
+ return first.request_state
+
+
+async def _retry(client: Client, amount: int, token: str) -> CallToolResult | InputRequiredResult:
+ """The retry: same tool, same arguments, the elicited answer, and the echoed token."""
+ return await client.session.call_tool(
+ "refund",
+ {"amount": amount},
+ input_responses={"ok": ElicitResult(action="accept", content={"ok": True})},
+ request_state=token,
+ allow_input_required=True,
+ )
+
+
+def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None:
+ """The one wire shape every inbound `requestState` verification failure produces."""
+ assert exc.value.error.code == INVALID_PARAMS
+ assert exc.value.error.message == "Invalid or expired requestState"
+ assert exc.value.error.data == {"reason": "invalid_request_state"}
+
+
+async def test_a_retry_that_reaches_a_different_worker_is_rejected_by_default() -> None:
+ """tutorial002: two default servers hold two `os.urandom(32)` keys, so a cross-instance retry is refused."""
+ worker_a = tutorial002.make_server()
+ worker_b = tutorial002.make_server()
+
+ with anyio.fail_after(5):
+ async with Client(worker_a) as on_a, Client(worker_b) as on_b:
+ token = await _first_round(on_a, 120)
+ with pytest.raises(MCPError) as exc:
+ await _retry(on_b, 120, token)
+ # Land back on the worker that minted the token and the identical retry completes.
+ second = await _retry(on_a, 120, token)
+
+ _assert_frozen_rejection(exc)
+ assert isinstance(second, CallToolResult)
+ assert second.content == [TextContent(type="text", text="refunded $120")]
+
+
+async def test_a_refund_the_human_declined_is_not_issued() -> None:
+ """tutorial002/003: the second round reads the answer, so anything but an accepted ok is no refund."""
+ server = tutorial002.make_server()
+ with anyio.fail_after(5):
+ async with Client(server) as client:
+ token = await _first_round(client, 120)
+ declined = await client.session.call_tool(
+ "refund",
+ {"amount": 120},
+ input_responses={"ok": ElicitResult(action="decline")},
+ request_state=token,
+ allow_input_required=True,
+ )
+ assert isinstance(declined, CallToolResult)
+ assert declined.content == [TextContent(type="text", text="refund cancelled")]
+
+
+async def test_a_shared_key_and_name_let_any_worker_finish_a_round_trip() -> None:
+ """tutorial003: instances built with the same key and the same name unseal what a sibling minted."""
+ worker_a = tutorial003.make_server(_KEY)
+ worker_b = tutorial003.make_server(_KEY)
+
+ with anyio.fail_after(5):
+ async with Client(worker_a) as on_a, Client(worker_b) as on_b:
+ token = await _first_round(on_a, 120)
+ second = await _retry(on_b, 120, token)
+
+ assert isinstance(second, CallToolResult)
+ assert not second.is_error
+ assert second.content == [TextContent(type="text", text="refunded $120")]
+
+
+async def test_a_shared_key_is_not_enough_without_a_shared_name() -> None:
+ """The `!!! warning`: the server name is the default `audience` claim, so keys alone don't cross instances."""
+
+ def named(name: str) -> MCPServer:
+ mcp = MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY]))
+
+ @mcp.tool()
+ async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
+ if ctx.input_responses is None:
+ return InputRequiredResult(input_requests={"ok": tutorial002.CONFIRM}, request_state="pending")
+ return f"refunded ${amount}"
+
+ return mcp
+
+ with anyio.fail_after(5):
+ async with Client(named("billing-1")) as on_one, Client(named("billing-2")) as on_two:
+ token = await _first_round(on_one, 120)
+ with pytest.raises(MCPError) as exc:
+ await _retry(on_two, 120, token)
+
+ _assert_frozen_rejection(exc)
+
+
+# -- change notifications across replicas ----------------------------------------------
+
+
+class _Stream:
+ """Collects a listen stream's frames and lets the test await arrival counts."""
+
+ def __init__(self) -> None:
+ self.received: list[object] = []
+ self._arrival = anyio.Event()
+
+ async def handler(self, message: object) -> None:
+ self.received.append(message)
+ self._arrival.set()
+ self._arrival = anyio.Event()
+
+ async def wait_for(self, count: int) -> None:
+ with anyio.fail_after(5):
+ while len(self.received) < count:
+ await self._arrival.wait()
+
+
+async def test_one_bus_carries_a_publish_on_one_replica_to_a_stream_on_another() -> None:
+ """tutorial004: a `subscriptions/listen` stream on replica A hears a publish that happened on replica B."""
+ bus = InMemorySubscriptionBus()
+ replica_a = tutorial004.make_server(bus)
+ replica_b = tutorial004.make_server(bus)
+ stream = _Stream()
+
+ with anyio.fail_after(10):
+ await _listen_and_edit(replica_a, replica_b, stream)
+
+
+async def _listen_and_edit(replica_a: MCPServer, replica_b: MCPServer, stream: _Stream) -> None:
+ """Open a listen stream on replica A, edit on replica B, and wait for the update to cross the bus."""
+ async with (
+ Client(replica_a, mode="2026-07-28", message_handler=stream.handler) as on_a,
+ Client(replica_b) as on_b,
+ ):
+ async with anyio.create_task_group() as tg:
+
+ async def listen() -> None:
+ await on_a.session.send_request(
+ SubscriptionsListenRequest(
+ params=SubscriptionsListenRequestParams(
+ notifications=SubscriptionFilter(resource_subscriptions=["note://todo"])
+ )
+ ),
+ SubscriptionsListenResult,
+ )
+
+ tg.start_soon(listen)
+ await stream.wait_for(1) # the acknowledgment: the stream is live on replica A
+
+ await on_b.call_tool("edit_note", {"name": "todo", "text": "water plants"})
+ await stream.wait_for(2)
+ updated = stream.received[1]
+ assert isinstance(updated, ResourceUpdatedNotification)
+ assert updated.params.uri == "note://todo"
+
+ tg.cancel_scope.cancel()
diff --git a/tests/docs_src/test_legacy_clients.py b/tests/docs_src/test_legacy_clients.py
new file mode 100644
index 000000000..f68732ac7
--- /dev/null
+++ b/tests/docs_src/test_legacy_clients.py
@@ -0,0 +1,136 @@
+"""`docs/run/legacy-clients.md`: every claim the page makes, proved against the real SDK."""
+
+import inspect
+
+import httpx
+import pytest
+from mcp_types import INVALID_REQUEST, ResourceUpdatedNotification, TextContent
+
+from docs_src.legacy_clients import tutorial001, tutorial002, tutorial003
+from mcp import Client, MCPError
+from mcp.client.streamable_http import streamable_http_client
+from mcp.server import MCPServer
+
+# See test_index.py for why this is a per-module mark and not a conftest hook.
+pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
+
+INITIALIZE = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
+}
+LIST_TOOLS = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
+MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
+URL = "http://localhost:8000/mcp"
+
+
+async def test_one_resolve_tool_serves_a_legacy_and_a_modern_client_at_once(
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ """tutorial001's `main()`, exactly as the page renders it: two eras of client, one server, one answer."""
+ await tutorial001.main()
+ assert capsys.readouterr().out == (
+ """2025-11-25 {'result': "Reserved 2 of 'Dune'."}\n2026-07-28 {'result': "Reserved 2 of 'Dune'."}\n"""
+ )
+
+
+async def test_neither_era_of_client_sees_the_resolved_parameter() -> None:
+ """tutorial001: there is one tool schema. The `Resolve`-filled parameter is hidden from both eras."""
+ async with Client(tutorial001.mcp, mode="legacy") as legacy, Client(tutorial001.mcp) as modern:
+ for client in (legacy, modern):
+ (tool,) = (await client.list_tools()).tools
+ assert set(tool.input_schema["properties"]) == {"title"}
+
+
+def test_streamable_http_app_has_no_era_knob() -> None:
+ """The opener: nothing in `streamable_http_app()`'s signature selects, rejects, or configures an era."""
+ parameters = set(inspect.signature(MCPServer.streamable_http_app).parameters) - {"self"}
+ assert parameters == {
+ "streamable_http_path",
+ "json_response",
+ "stateless_http",
+ "event_store",
+ "retry_interval",
+ "transport_security",
+ "host",
+ }
+
+
+async def test_a_legacy_session_is_minted_in_process_and_a_stray_session_id_is_a_404() -> None:
+ """The cost section: a legacy `initialize` gets an `Mcp-Session-Id`, and a request naming a session
+ this process never minted gets a `404`. That miss is exactly what a load balancer without sticky
+ routing produces."""
+ app = MCPServer("Bookshop").streamable_http_app()
+ async with (
+ app.router.lifespan_context(app),
+ httpx.ASGITransport(app) as transport,
+ httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
+ ):
+ opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
+ assert opened.status_code == 200
+ assert opened.headers["mcp-session-id"]
+
+ stray = await http.post("/mcp", json=LIST_TOOLS, headers={**MCP_HEADERS, "Mcp-Session-Id": 32 * "f"})
+ assert stray.status_code == 404
+
+
+async def test_stateless_http_never_mints_a_session() -> None:
+ """The `stateless_http=True` section: the same legacy `initialize` no longer gets an `Mcp-Session-Id`."""
+ app = MCPServer("Bookshop").streamable_http_app(stateless_http=True)
+ async with (
+ app.router.lifespan_context(app),
+ httpx.ASGITransport(app) as transport,
+ httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
+ ):
+ opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
+ assert opened.status_code == 200
+ assert "mcp-session-id" not in opened.headers
+
+
+async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_one() -> None:
+ """tutorial002: over the same `stateless_http=True` app, the modern client still gets its answer and
+ the legacy client's call fails as the top-level `MCPError` the `!!! check` quotes."""
+ async with (
+ tutorial002.app.router.lifespan_context(tutorial002.app),
+ httpx.ASGITransport(tutorial002.app) as transport,
+ httpx.AsyncClient(transport=transport) as http,
+ ):
+ modern_target = streamable_http_client(URL, http_client=http)
+ async with Client(modern_target, elicitation_callback=tutorial001.answer) as modern:
+ assert modern.protocol_version == "2026-07-28"
+ result = await modern.call_tool("reserve", {"title": "Dune"})
+ assert result.content == [TextContent(type="text", text="Reserved 2 of 'Dune'.")]
+
+ legacy_target = streamable_http_client(URL, http_client=http)
+ async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001.answer) as legacy:
+ assert legacy.protocol_version == "2025-11-25"
+ with pytest.raises(MCPError) as exc_info:
+ await legacy.call_tool("reserve", {"title": "Dune"})
+ assert exc_info.value.error.code == INVALID_REQUEST
+ assert exc_info.value.error.message == (
+ "Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests."
+ )
+
+
+async def test_the_legacy_notification_verb_reaches_a_legacy_client() -> None:
+ """tutorial003: `ctx.session.send_resource_updated` lands on the legacy client's standalone stream."""
+ received: list[object] = []
+
+ async def on_message(message: object) -> None:
+ received.append(message)
+
+ async with Client(tutorial003.mcp, mode="legacy", message_handler=on_message) as client:
+ result = await client.call_tool("restock", {"title": "Dune", "copies": 2})
+ assert not result.is_error
+ (notification,) = received
+ assert isinstance(notification, ResourceUpdatedNotification)
+ assert notification.params.uri == "stock://Dune"
+
+
+async def test_calling_both_notification_verbs_is_safe_on_both_eras() -> None:
+ """tutorial003: the two-line fork never errors, whichever era the caller is on."""
+ async with Client(tutorial003.mcp, mode="legacy") as legacy, Client(tutorial003.mcp) as modern:
+ for client in (legacy, modern):
+ result = await client.call_tool("restock", {"title": "Dune", "copies": 1})
+ assert not result.is_error
diff --git a/tests/docs_src/test_real_host.py b/tests/docs_src/test_real_host.py
new file mode 100644
index 000000000..36b0670d0
--- /dev/null
+++ b/tests/docs_src/test_real_host.py
@@ -0,0 +1,54 @@
+"""`docs/get-started/real-host.md`: the one server every host section on the page launches, driven in memory."""
+
+import pytest
+from inline_snapshot import snapshot
+from mcp_types import TextContent, TextResourceContents
+
+from docs_src.real_host import tutorial001
+from mcp import Client
+
+# See test_index.py for why this is a per-module mark and not a conftest hook.
+pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
+
+
+async def test_the_host_sees_exactly_what_the_decorators_registered() -> None:
+ """tutorial001: `tools/list` is what a host hands its model. Name, description, and schema come from the code."""
+ async with Client(tutorial001.mcp) as client:
+ search, get = (await client.list_tools()).tools
+ assert search.name == "search_books"
+ assert search.description == "Search the catalog by title or author."
+ assert search.input_schema == snapshot(
+ {
+ "type": "object",
+ "properties": {"query": {"title": "Query", "type": "string"}},
+ "required": ["query"],
+ "title": "search_booksArguments",
+ }
+ )
+ assert get.name == "get_author"
+
+
+async def test_a_tool_call_round_trips_the_way_a_host_drives_it() -> None:
+ """tutorial001: `tools/call` sends arguments in; the function's return value comes back as the result."""
+ async with Client(tutorial001.mcp) as client:
+ result = await client.call_tool("search_books", {"query": "gibson"})
+ assert not result.is_error
+ assert result.structured_content == {"result": ["Neuromancer"]}
+
+ author = await client.call_tool("get_author", {"title": "Dune"})
+ assert author.content == [TextContent(type="text", text="Frank Herbert")]
+
+
+async def test_the_resource_a_host_can_attach_to_context() -> None:
+ """tutorial001: `catalog://titles` has no parameter, so it is a concrete, listable, readable resource."""
+ async with Client(tutorial001.mcp) as client:
+ (resource,) = (await client.list_resources()).resources
+ assert str(resource.uri) == "catalog://titles"
+ result = await client.read_resource("catalog://titles")
+ assert result.contents == [
+ TextResourceContents(
+ uri="catalog://titles",
+ mime_type="text/plain",
+ text="Dune\nNeuromancer\nThe Left Hand of Darkness",
+ )
+ ]
diff --git a/tests/docs_src/test_troubleshooting.py b/tests/docs_src/test_troubleshooting.py
new file mode 100644
index 000000000..5c091f054
--- /dev/null
+++ b/tests/docs_src/test_troubleshooting.py
@@ -0,0 +1,278 @@
+"""`docs/troubleshooting.md`: every error string the page names, reproduced against the real SDK."""
+
+import logging
+from typing import Any
+
+import httpx
+import pytest
+from mcp_types import (
+ INVALID_PARAMS,
+ INVALID_REQUEST,
+ METHOD_NOT_FOUND,
+ MISSING_REQUIRED_CLIENT_CAPABILITY,
+ ElicitRequestParams,
+ ElicitResult,
+ ErrorData,
+ TextContent,
+)
+
+from docs_src.troubleshooting import (
+ tutorial001,
+ tutorial002,
+ tutorial003,
+ tutorial004,
+ tutorial005,
+ tutorial006,
+ tutorial007,
+ tutorial008,
+)
+from mcp import Client, MCPError
+from mcp.client import ClientRequestContext
+from mcp.client.streamable_http import streamable_http_client
+from mcp.server import MCPServer
+from mcp.server.mcpserver import RequestStateSecurity
+
+# See test_index.py for why this is a per-module mark and not a conftest hook.
+pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
+
+INITIALIZE = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
+}
+MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
+
+
+async def _confirm(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
+ """The page's one `elicitation_callback`: always accept the booking."""
+ return ElicitResult(action="accept", content={"confirm": True})
+
+
+async def test_an_error_leaving_the_async_with_block_arrives_wrapped_in_an_exception_group() -> None:
+ """The `unhandled errors in a TaskGroup` entry: anyio group-wraps whatever escapes the block."""
+ with pytest.raises(Exception) as exc_info:
+ async with Client(tutorial001.mcp) as client:
+ await client.read_resource("weather://Atlantis")
+ assert not isinstance(exc_info.value, MCPError)
+ assert exc_info.group_contains(MCPError, match=r"^No forecast for 'Atlantis'\.$")
+
+
+async def test_the_same_error_caught_inside_the_block_is_the_bare_mcp_error() -> None:
+ """The fix on the page: `except MCPError` inside the `async with` never sees an `ExceptionGroup`."""
+ async with Client(tutorial001.mcp) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.read_resource("weather://Atlantis")
+ assert str(exc_info.value) == "No forecast for 'Atlantis'."
+ assert exc_info.value.error.code == INVALID_PARAMS
+
+
+async def test_a_client_outside_its_async_with_refuses_every_call() -> None:
+ """`Client(...)` only constructs. Nothing connects until `async with`, so every call refuses."""
+ client = Client(tutorial001.mcp)
+ with pytest.raises(RuntimeError, match="^Client must be used within an async context manager$"):
+ await client.list_tools()
+
+
+async def test_a_failing_tool_returns_is_error_true_instead_of_raising() -> None:
+ """The `Error executing tool` entry: it is a result, not an exception. Nothing to `except`."""
+ async with Client(tutorial001.mcp) as client:
+ result = await client.call_tool("forecast", {"city": "Atlantis"})
+ assert result.is_error
+ assert result.content == [
+ TextContent(type="text", text="Error executing tool forecast: No forecast for 'Atlantis'.")
+ ]
+
+
+async def test_an_unknown_tool_is_the_same_kind_of_result() -> None:
+ """`Unknown tool: ` travels the same `is_error=True` path as a failing tool."""
+ async with Client(tutorial001.mcp) as client:
+ result = await client.call_tool("get_forecast", {"city": "London"})
+ assert result.is_error
+ assert result.content == [TextContent(type="text", text="Unknown tool: get_forecast")]
+
+
+async def test_the_tool_decorator_without_parentheses_raises_at_import_time() -> None:
+ """`@mcp.tool` (no parentheses) hands the function itself to `name=`; the SDK refuses immediately."""
+ mcp = MCPServer("Weather")
+ undecorated: Any = mcp.tool
+ with pytest.raises(TypeError, match=r"Use @tool\(\) instead of @tool"):
+
+ @undecorated
+ def forecast(city: str) -> str:
+ """Today's forecast for one city."""
+ return f"{city}: Rain."
+
+
+async def test_a_duplicate_tool_name_keeps_the_first_and_drops_the_second() -> None:
+ """tutorial002: `tools/list` reports one `forecast`, and it is the first registration that won."""
+ async with Client(tutorial002.mcp) as client:
+ (tool,) = (await client.list_tools()).tools
+ assert tool.name == "forecast"
+ assert tool.description == "Today's forecast for one city."
+
+
+async def test_a_duplicate_registration_logs_tool_already_exists(caplog: pytest.LogCaptureFixture) -> None:
+ """The only signal for a dropped duplicate is the `Tool already exists:` warning in the server log."""
+ with caplog.at_level(logging.WARNING, logger="mcp.server.mcpserver.tools.tool_manager"):
+
+ @tutorial002.mcp.tool(name="forecast")
+ def forecast_weekly(city: str) -> str:
+ """The week ahead for one city."""
+ return f"{city}: Rain all week."
+
+ assert "Tool already exists: forecast" in caplog.messages
+
+
+async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """tutorial003: one 421, three spellings. The page presents all three as the same event."""
+ transport = httpx.ASGITransport(app=tutorial003.app)
+ async with tutorial003.mcp.session_manager.run():
+ # What curl (or the reverse proxy's access log) shows: the status and the plain-text body.
+ async with httpx.AsyncClient(transport=transport, base_url="http://mcp.example.com") as raw:
+ with caplog.at_level(logging.WARNING, logger="mcp.server.transport_security"):
+ response = await raw.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
+ assert (response.status_code, response.text) == (421, "Invalid Host header")
+ # No `Content-Type: application/json`, which is exactly why the python client cannot show the body.
+ assert response.headers.get("content-type") is None
+ # What the server operator finds by grepping the server log.
+ assert "Invalid Host header: mcp.example.com" in caplog.messages
+ # What the python `Client` raises instead: the generic stand-in, wrapped by the task group.
+ async with httpx.AsyncClient(transport=transport) as http_client:
+ with pytest.raises(Exception) as exc_info:
+ async with Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)):
+ pass # never reached: the handshake itself is what fails
+ assert not isinstance(exc_info.value, MCPError)
+ assert exc_info.group_contains(MCPError, match="^Server returned an error response$")
+
+
+async def test_an_allowlisted_hostname_connects_and_calls_a_tool() -> None:
+ """tutorial004: `transport_security=` names the deployed hostname, and the same client connects."""
+ transport = httpx.ASGITransport(app=tutorial004.app)
+ async with tutorial004.mcp.session_manager.run():
+ async with httpx.AsyncClient(transport=transport) as http_client:
+ async with Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)) as c:
+ assert c.protocol_version == "2026-07-28"
+ result = await c.call_tool("forecast", {"city": "London"})
+ assert result.structured_content == {"result": "London: Rain."}
+
+
+async def test_a_mounted_app_without_a_lifespan_fails_on_the_first_request() -> None:
+ """tutorial005: Starlette never runs a mounted sub-app's lifespan, so nothing starts the manager."""
+ transport = httpx.ASGITransport(app=tutorial005.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http:
+ with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."):
+ await http.post("/mcp")
+
+
+async def test_a_session_id_the_server_never_issued_gets_a_404_session_not_found() -> None:
+ """`Session not found` is a 404 with a JSON-RPC body, so the python `Client` surfaces it verbatim."""
+ mcp = MCPServer("Weather")
+ app = mcp.streamable_http_app()
+ async with mcp.session_manager.run():
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1:8000") as h:
+ response = await h.post(
+ "/mcp",
+ json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
+ headers={**MCP_HEADERS, "mcp-session-id": "deadbeef"},
+ )
+ assert response.status_code == 404
+ assert response.headers["content-type"] == "application/json"
+ assert response.json() == {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}}
+
+
+async def test_ctx_elicit_at_2026_raises_method_not_found() -> None:
+ """tutorial006: at 2026-07-28 there is no server-to-client `elicitation/create` for the tool to send."""
+ async with Client(tutorial006.mcp) as client:
+ assert client.protocol_version == "2026-07-28"
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("book_table", {"date": "Friday"})
+ assert exc_info.value.error == ErrorData(
+ code=METHOD_NOT_FOUND, message="Method not found", data="elicitation/create"
+ )
+
+
+async def test_an_elicitation_callback_does_not_fix_ctx_elicit_at_2026() -> None:
+ """The page's `!!! warning`: registering the callback changes nothing. The method itself is gone."""
+ async with Client(tutorial006.mcp, elicitation_callback=_confirm) as client:
+ with pytest.raises(MCPError, match="^Method not found$"):
+ await client.call_tool("book_table", {"date": "Friday"})
+
+
+async def test_ctx_elicit_on_a_legacy_connection_works() -> None:
+ """The legacy aside: `ctx.elicit` is a server-to-client request, and only a legacy session has those."""
+ async with Client(tutorial006.mcp, mode="legacy", elicitation_callback=_confirm) as client:
+ result = await client.call_tool("book_table", {"date": "Friday"})
+ assert result.structured_content == {"result": "Booked for Friday."}
+
+
+async def test_the_resolver_form_works_on_a_2026_connection() -> None:
+ """tutorial007: the fix. Same question, same callback, but the server returns it instead of calling back."""
+ async with Client(tutorial007.mcp, elicitation_callback=_confirm) as client:
+ assert client.protocol_version == "2026-07-28"
+ result = await client.call_tool("book_table", {"date": "Friday"})
+ assert result.structured_content == {"result": "Booked for Friday."}
+
+
+async def test_the_resolver_form_without_a_callback_names_the_missing_capability() -> None:
+ """The `-32021` entry: the server refuses up front, and `data` names the capability to declare."""
+ async with Client(tutorial007.mcp) as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("book_table", {"date": "Friday"})
+ assert exc_info.value.error == ErrorData(
+ code=MISSING_REQUIRED_CLIENT_CAPABILITY,
+ message=(
+ "Client did not declare the form elicitation capability required by resolver "
+ "'docs_src.troubleshooting.tutorial007:ask_to_confirm'"
+ ),
+ data={"requiredCapabilities": {"elicitation": {"form": {}}}},
+ )
+
+
+async def test_a_legacy_ctx_elicit_without_a_callback_says_elicitation_not_supported() -> None:
+ """The `Elicitation not supported` entry: no `elicitation_callback` means nobody to ask."""
+ async with Client(tutorial006.mcp, mode="legacy") as client:
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("book_table", {"date": "Friday"})
+ assert exc_info.value.error == ErrorData(code=INVALID_REQUEST, message="Elicitation not supported")
+
+
+async def test_ctx_elicit_over_stateless_http_has_no_back_channel() -> None:
+ """tutorial008: `stateless_http=True` leaves the server no channel to send `elicitation/create`."""
+ transport = httpx.ASGITransport(app=tutorial008.app)
+ async with tutorial008.mcp.session_manager.run():
+ async with httpx.AsyncClient(transport=transport) as http_client:
+ async with Client(streamable_http_client("http://127.0.0.1:8000/mcp", http_client=http_client)) as c:
+ with pytest.raises(MCPError) as exc_info:
+ await c.call_tool("book_table", {"date": "Friday"})
+ assert exc_info.value.error == ErrorData(
+ code=INVALID_REQUEST,
+ message=(
+ "Cannot send 'elicitation/create': "
+ "this transport context has no back-channel for server-initiated requests."
+ ),
+ )
+
+
+async def test_a_request_state_the_server_did_not_mint_is_rejected(caplog: pytest.LogCaptureFixture) -> None:
+ """The wire message is deliberately frozen; the real reason goes only to the server log."""
+ async with Client(tutorial001.mcp) as client:
+ with caplog.at_level(logging.WARNING, logger="mcp.server.request_state"):
+ with pytest.raises(MCPError) as exc_info:
+ await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a")
+ assert exc_info.value.error == ErrorData(
+ code=INVALID_PARAMS, message="Invalid or expired requestState", data={"reason": "invalid_request_state"}
+ )
+ assert "requestState rejected on tools/call: malformed" in caplog.messages
+
+
+async def test_a_short_request_state_key_is_rejected_at_construction() -> None:
+ """`RequestStateSecurity(keys=[...])` refuses anything under 32 bytes and says how to make one."""
+ with pytest.raises(ValueError) as exc_info:
+ RequestStateSecurity(keys=[b"hunter2"])
+ assert str(exc_info.value) == (
+ "request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. "
+ 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
+ )
diff --git a/tests/test_examples.py b/tests/test_examples.py
index 9a329c47a..9236503a9 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -104,6 +104,7 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"docs/index.md",
"docs/protocol-versions.md",
"docs/deprecated.md",
+ "docs/troubleshooting.md",
"docs/get-started",
"docs/servers",
"docs/handlers",
From 01128d6ae5b95d4907e5e3eef060d8e7ca0cfccd Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 18:14:36 +0000
Subject: [PATCH 10/15] docs: ctx.elicit() at 2026-07-28 is refused server-side
since #3040
Rebasing onto main picked up #3040, which hardened the era semantics:
a server-initiated request on a 2026-07-28 connection is now refused by
the SERVER, as
MCPError: Cannot send 'elicitation/create': this transport context
has no back-channel for server-initiated requests.
instead of reaching the client and being rejected there as "Method not
found". Two of this branch's docs tests failed on the rebase -- which
is the point of testing every example -- and the troubleshooting page
keyed its biggest elicitation entry on the old string.
So the two entries merge into the one that owns the surviving string.
"Method not found" is now short and generic (an era mismatch: a method
one protocol revision has and the other does not), and says explicitly
that ctx.elicit() at 2026-07-28 no longer produces it. The "Cannot
send 'elicitation/create' ..." entry becomes the single home for "your
handler reached back and nothing can carry it", with its two real
triggers -- any 2026-07-28 connection, and a legacy connection on a
stateless_http=True server -- both shown from tested examples, and the
one fix (a resolver). The tests pin the new behaviour, the same way
#3040 itself updated tests/docs_src/test_client_callbacks.py.
---
docs/troubleshooting.md | 68 +++++++++++++-------------
tests/docs_src/test_troubleshooting.py | 15 +++---
2 files changed, 43 insertions(+), 40 deletions(-)
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index c4ff6744e..f5ec9f328 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -260,37 +260,9 @@ For the server operator, the matching log line is `Rejected request with unknown
## `MCPError: Method not found`
-One side sent a JSON-RPC request the other side has no handler for, and `e.error.data` names the method. The one everybody hits has `data` equal to `elicitation/create`:
+One side sent a JSON-RPC request the other has no handler for, and `e.error.data` names the method. The usual cause is an **era mismatch**: a method that exists in one protocol revision and not in the other, sent to a peer on the wrong one — a `2025`-era `resources/subscribe` arriving at a `2026-07-28` connection, a `2026`-only `subscriptions/listen` sent by a client pinned to `mode="legacy"`. **[Protocol versions](protocol-versions.md)** is the map of which side speaks what, and the other honest cause — an optional capability you never registered a handler for — is on **[Completions](servers/completions.md)**.
-```python title="server.py" hl_lines="16"
---8<-- "docs_src/troubleshooting/tutorial006.py"
-```
-
-```python
-async def main() -> None:
- async with Client(mcp) as client:
- await client.call_tool("book_table", {"date": "Friday"})
-```
-
-```text
-mcp.shared.exceptions.MCPError: Method not found
-```
-
-`ctx.elicit()` (and `ctx.elicit_url()`) is a request from the *server* to the *client*, and the **2026-07-28** protocol has no server-initiated requests. The in-memory `Client(server)` negotiates `2026-07-28` without being asked, so this fails on the very first test, and passing `elicitation_callback=` changes nothing: the method itself is what's missing, not the handler.
-
-The fix is to move the question out of the tool body and into a **resolver**, which works on every protocol version:
-
-```python title="server.py" hl_lines="15-17 21"
---8<-- "docs_src/troubleshooting/tutorial007.py"
-```
-
-Same question, same `elicitation_callback` on the client. The difference is under the hood: a resolver lets the server *return* the question from the call instead of pushing it, so nothing ever flows server-to-client. **[Elicitation](handlers/elicitation.md)** covers resolvers; **[Multi-round-trip requests](handlers/multi-round-trip.md)** covers what actually happens on the wire.
-
-!!! check
- The tool with `ctx.elicit()` is not wrong, it is *pre-2026*. Connect with `mode="legacy"`
- (the classic `initialize` handshake, spec `2025-11-25` and earlier) and it works, because the
- server-to-client channel exists there. **[Protocol versions](protocol-versions.md)** is the
- page on what each version has.
+One thing does **not** produce this error, despite being a request the modern protocol removed: a tool calling `ctx.elicit()` on a `2026-07-28` connection. The server refuses to *send* that request at all, so what you get instead is `Cannot send 'elicitation/create': ...`, further down this page.
## `MCPError: Client did not declare the form elicitation capability required by resolver ''`
@@ -333,9 +305,25 @@ You see this one from `ctx.elicit()` on a legacy connection, and — on any conn
## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.`
-Your handler tried to reach the client mid-request, on a transport where nothing can.
+Your handler tried to reach the client mid-request, on a connection where nothing can carry a request from the server. There are exactly two ways to be on one.
-Stateless HTTP is the usual trigger. `stateless_http=True` means every request is its own world: no session, no server-to-client stream, and so nowhere to send an `elicitation/create` (or `sampling/createMessage`, or `roots/list`):
+**A `2026-07-28` connection — any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this — on the very first in-memory test, since `Client(server)` negotiates `2026-07-28` without being asked — and passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer:
+
+```python title="server.py" hl_lines="16"
+--8<-- "docs_src/troubleshooting/tutorial006.py"
+```
+
+```python
+async def main() -> None:
+ async with Client(mcp) as client:
+ await client.call_tool("book_table", {"date": "Friday"})
+```
+
+```text
+mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
+```
+
+**A legacy connection on a `stateless_http=True` server.** Statelessness means every request is its own world: no session, no server-to-client stream, and so nowhere to send an `elicitation/create` (or `sampling/createMessage`, or `roots/list`) even for the era that has them:
```python title="server.py" hl_lines="16 23"
--8<-- "docs_src/troubleshooting/tutorial008.py"
@@ -343,7 +331,19 @@ Stateless HTTP is the usual trigger. `stateless_http=True` means every request i
The message names the method it could not send. `NoBackChannelError` is the class the server raises, but the wire carries only the base `MCPError`, so the sentence above is your traceback's last line, not the class name.
-The fix is the same as for `Method not found`: don't reach back mid-call. A **resolver** (or a returned `InputRequiredResult`) turns the question into part of the *response*, which every transport can carry, stateless or not. **[Multi-round-trip requests](handlers/multi-round-trip.md)** is that mechanism.
+The fix is the same for both: don't reach back mid-call. Move the question into a **resolver** (or return an `InputRequiredResult` yourself) and it becomes part of the *response*, which every connection can carry:
+
+```python title="server.py" hl_lines="15-17 21"
+--8<-- "docs_src/troubleshooting/tutorial007.py"
+```
+
+Same question, same `elicitation_callback` on the client. The difference is under the hood: a resolver lets the server *return* the question from the call instead of pushing it, so nothing ever flows server-to-client. **[Elicitation](handlers/elicitation.md)** covers resolvers; **[Multi-round-trip requests](handlers/multi-round-trip.md)** covers what happens on the wire.
+
+!!! check
+ The tool with `ctx.elicit()` is not wrong, it is *pre-2026*. Connect with `mode="legacy"`
+ (the classic `initialize` handshake, spec `2025-11-25` and earlier) to a server that is not
+ `stateless_http=True`, and it works, because the server-to-client channel exists there.
+ **[Protocol versions](protocol-versions.md)** is the page on what each version has.
## `MCPError: Invalid or expired requestState`
@@ -407,6 +407,6 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: ` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`.
* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.
* `Session not found` -> the server restarted; reconnect.
-* `Method not found` on `elicitation/create` -> `ctx.elicit()` needs a server-to-client channel and `2026-07-28` has none. Use a resolver.
+* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, and `stateless_http=True` takes away the legacy one. Use a resolver. Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have.
* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.
diff --git a/tests/docs_src/test_troubleshooting.py b/tests/docs_src/test_troubleshooting.py
index 5c091f054..a810182c7 100644
--- a/tests/docs_src/test_troubleshooting.py
+++ b/tests/docs_src/test_troubleshooting.py
@@ -8,7 +8,6 @@
from mcp_types import (
INVALID_PARAMS,
INVALID_REQUEST,
- METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
ElicitRequestParams,
ElicitResult,
@@ -183,21 +182,25 @@ async def test_a_session_id_the_server_never_issued_gets_a_404_session_not_found
assert response.json() == {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}}
-async def test_ctx_elicit_at_2026_raises_method_not_found() -> None:
- """tutorial006: at 2026-07-28 there is no server-to-client `elicitation/create` for the tool to send."""
+async def test_ctx_elicit_at_2026_has_no_back_channel() -> None:
+ """tutorial006: at 2026-07-28 the server refuses to send `elicitation/create` at all."""
async with Client(tutorial006.mcp) as client:
assert client.protocol_version == "2026-07-28"
with pytest.raises(MCPError) as exc_info:
await client.call_tool("book_table", {"date": "Friday"})
assert exc_info.value.error == ErrorData(
- code=METHOD_NOT_FOUND, message="Method not found", data="elicitation/create"
+ code=INVALID_REQUEST,
+ message=(
+ "Cannot send 'elicitation/create': "
+ "this transport context has no back-channel for server-initiated requests."
+ ),
)
async def test_an_elicitation_callback_does_not_fix_ctx_elicit_at_2026() -> None:
- """The page's `!!! warning`: registering the callback changes nothing. The method itself is gone."""
+ """The page's claim: registering the callback changes nothing. No request ever reaches the client."""
async with Client(tutorial006.mcp, elicitation_callback=_confirm) as client:
- with pytest.raises(MCPError, match="^Method not found$"):
+ with pytest.raises(MCPError, match="no back-channel for server-initiated requests"):
await client.call_tool("book_table", {"date": "Friday"})
From 7d3ea3bb3bdaa74a50e08c49eccbb51c6ae4a9bb Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 18:19:01 +0000
Subject: [PATCH 11/15] docs: say out loud that mcp.run() is a stdio server, on
the host page
"Connect to a real host" showed a server file ending in mcp.run() and
never said what that call does, which is the one fact the whole page
stands on: with no arguments it is a STDIO server -- it blocks, reads
protocol messages on stdin, writes them on stdout, and never opens a
port. That is why every host on the page is configured with a command
rather than an address. Running your server explains this, but a
reader lands on this page from the Get started sequence (or a search)
without having read that one.
The same page now also answers the two questions a reader has next, in
one note: this is the LOCAL story (to serve people who don't have your
file, you hand out a URL -- Running your server, then Deploy & scale),
and a host is just an application with an MCP client inside, so your
own Python can play that part (Client transports launches the same
file with stdio_client). Client transports points back the other way.
---
docs/client/transports.md | 2 +-
docs/get-started/real-host.md | 16 +++++++++++++++-
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/docs/client/transports.md b/docs/client/transports.md
index 5554bdd46..ef51f7ee5 100644
--- a/docs/client/transports.md
+++ b/docs/client/transports.md
@@ -72,7 +72,7 @@ Two things to notice:
## stdio
-A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine.
+A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine — a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file.
Describe the process with `StdioServerParameters`, turn it into a transport with `stdio_client`, and hand *that* to `Client`:
diff --git a/docs/get-started/real-host.md b/docs/get-started/real-host.md
index cec7f3d03..db375c1a7 100644
--- a/docs/get-started/real-host.md
+++ b/docs/get-started/real-host.md
@@ -10,8 +10,9 @@ Which means connecting to a host is one act: you tell it **the command that star
--8<-- "docs_src/real_host/tutorial001.py"
```
-Two tools and a resource, one file. Two things about that file matter to every host below:
+Two tools and a resource, one file. Three things about that file matter to every host below:
+* `mcp.run()` with no arguments starts a **stdio** server: it blocks, reads protocol messages on stdin, and writes them on stdout. That is the transport every host on this page speaks — the host starts your file as a child process and owns those two pipes — and it is why connecting is only ever "here is the command". You never pick a port, and nothing listens on one.
* `run()` is under `if __name__ == "__main__":`. Everything below **imports** this file rather than executing it, so an unguarded `run()` would start a server the moment anything loaded the module.
* The server object is a module-level global named `mcp`. That's the name `mcp run` looks for (`server` and `app` also work). Call it something else and you name it explicitly: `mcp run server.py:bookshop`.
@@ -39,6 +40,19 @@ It is also the command `mcp install` writes into Claude Desktop's config for you
`uv` with the absolute path from `which uv` (macOS/Linux) or `where uv` (Windows). That is
exactly what `mcp install` writes.
+!!! note "This page is the local story"
+ Everything here runs your server on the machine the host is on: the host launches your
+ file, over stdio. That is exactly right for a personal or single-machine tool. To give a
+ server to people who do *not* have your file, you hand out a **URL**, not a command — the
+ same `mcp` object served over Streamable HTTP. **[Running your server](../run/index.md)**
+ is that decision in one table, and **[Deploy & scale](../run/deploy.md)** is the road from
+ there to a real hostname.
+
+ And a host is nothing more than an application with an MCP client inside it, so your own
+ Python can play the host's part: **[Client transports](../client/transports.md)** launches
+ this same file as a subprocess with `stdio_client(...)`, and **[Testing](testing.md)**
+ connects to it in memory with no process at all.
+
## Claude Desktop
The one host the SDK can configure for you:
From 9338291bc653cec5a4f1127d7c9cd3587b02d1f4 Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 18:40:52 +0000
Subject: [PATCH 12/15] tests: close the docs test modules' branch-coverage
gaps
CI requires 100% branch coverage over tests/ as well as src/, and the
new docs test modules left four statements and a handful of branch arcs
uncovered.
The statements were real gaps, each closed by making the test stronger
rather than weaker:
- test_deploy: the shared-key-different-name test now also lands the
retry back on the instance that minted the token and asserts it
completes. That is the half of the story the page tells, and it is
exactly what the sibling default-key test already proved.
- test_troubleshooting: two decorated functions whose bodies can never
run (one's decoration is what raises; the other is the duplicate that
gets dropped) now have docstring-only bodies, and the
connection-fails case enters the client explicitly with __aenter__()
(the shape tests/client/test_client.py already uses) instead of an
`async with` whose body is unreachable by design.
The rest were not real: every remaining flagged line executes (zero
missed statements); coverage.py misattributes arcs around nested
`async with` bodies on newer Pythons, worst on 3.14, which is exactly
the case AGENTS.md documents for `# pragma: no branch` (branch arcs
only; ~180 existing uses across src/ and tests/). Six of those, one of
them on a straight-line test that raises nothing at all.
./scripts/test (the CI-equivalent gate) now reports 100.00% and
strict-no-cover passes.
---
tests/docs_src/test_deploy.py | 4 ++++
tests/docs_src/test_legacy_clients.py | 2 +-
tests/docs_src/test_troubleshooting.py | 26 +++++++++++++-------------
3 files changed, 18 insertions(+), 14 deletions(-)
diff --git a/tests/docs_src/test_deploy.py b/tests/docs_src/test_deploy.py
index 64ea65c95..dde5f648d 100644
--- a/tests/docs_src/test_deploy.py
+++ b/tests/docs_src/test_deploy.py
@@ -160,8 +160,12 @@ async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
token = await _first_round(on_one, 120)
with pytest.raises(MCPError) as exc:
await _retry(on_two, 120, token)
+ # Same keys AND the same name: back on the instance that minted it, the retry completes.
+ second = await _retry(on_one, 120, token)
_assert_frozen_rejection(exc)
+ assert isinstance(second, CallToolResult)
+ assert second.content == [TextContent(type="text", text="refunded $120")]
# -- change notifications across replicas ----------------------------------------------
diff --git a/tests/docs_src/test_legacy_clients.py b/tests/docs_src/test_legacy_clients.py
index f68732ac7..0dfd0af93 100644
--- a/tests/docs_src/test_legacy_clients.py
+++ b/tests/docs_src/test_legacy_clients.py
@@ -105,7 +105,7 @@ async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_
legacy_target = streamable_http_client(URL, http_client=http)
async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001.answer) as legacy:
assert legacy.protocol_version == "2025-11-25"
- with pytest.raises(MCPError) as exc_info:
+ with pytest.raises(MCPError) as exc_info: # pragma: no branch
await legacy.call_tool("reserve", {"title": "Dune"})
assert exc_info.value.error.code == INVALID_REQUEST
assert exc_info.value.error.message == (
diff --git a/tests/docs_src/test_troubleshooting.py b/tests/docs_src/test_troubleshooting.py
index a810182c7..ee53c2649 100644
--- a/tests/docs_src/test_troubleshooting.py
+++ b/tests/docs_src/test_troubleshooting.py
@@ -98,9 +98,8 @@ async def test_the_tool_decorator_without_parentheses_raises_at_import_time() ->
with pytest.raises(TypeError, match=r"Use @tool\(\) instead of @tool"):
@undecorated
- def forecast(city: str) -> str:
- """Today's forecast for one city."""
- return f"{city}: Rain."
+ def forecast(city: str) -> None:
+ """Today's forecast for one city. Never called: the decoration itself is what raises."""
async def test_a_duplicate_tool_name_keeps_the_first_and_drops_the_second() -> None:
@@ -116,9 +115,8 @@ async def test_a_duplicate_registration_logs_tool_already_exists(caplog: pytest.
with caplog.at_level(logging.WARNING, logger="mcp.server.mcpserver.tools.tool_manager"):
@tutorial002.mcp.tool(name="forecast")
- def forecast_weekly(city: str) -> str:
- """The week ahead for one city."""
- return f"{city}: Rain all week."
+ def forecast_weekly(city: str) -> None:
+ """The week ahead for one city. Never called: it is the duplicate that gets dropped."""
assert "Tool already exists: forecast" in caplog.messages
@@ -140,9 +138,9 @@ async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
assert "Invalid Host header: mcp.example.com" in caplog.messages
# What the python `Client` raises instead: the generic stand-in, wrapped by the task group.
async with httpx.AsyncClient(transport=transport) as http_client:
- with pytest.raises(Exception) as exc_info:
- async with Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)):
- pass # never reached: the handshake itself is what fails
+ client = Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client))
+ with pytest.raises(Exception) as exc_info: # pragma: no branch
+ await client.__aenter__() # the connection attempt itself is what fails
assert not isinstance(exc_info.value, MCPError)
assert exc_info.group_contains(MCPError, match="^Server returned an error response$")
@@ -152,7 +150,8 @@ async def test_an_allowlisted_hostname_connects_and_calls_a_tool() -> None:
transport = httpx.ASGITransport(app=tutorial004.app)
async with tutorial004.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport) as http_client:
- async with Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)) as c:
+ allowed = streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)
+ async with Client(allowed) as c: # pragma: no branch
assert c.protocol_version == "2026-07-28"
result = await c.call_tool("forecast", {"city": "London"})
assert result.structured_content == {"result": "London: Rain."}
@@ -247,8 +246,9 @@ async def test_ctx_elicit_over_stateless_http_has_no_back_channel() -> None:
transport = httpx.ASGITransport(app=tutorial008.app)
async with tutorial008.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport) as http_client:
- async with Client(streamable_http_client("http://127.0.0.1:8000/mcp", http_client=http_client)) as c:
- with pytest.raises(MCPError) as exc_info:
+ stateless = streamable_http_client("http://127.0.0.1:8000/mcp", http_client=http_client)
+ async with Client(stateless) as c: # pragma: no branch
+ with pytest.raises(MCPError) as exc_info: # pragma: no branch
await c.call_tool("book_table", {"date": "Friday"})
assert exc_info.value.error == ErrorData(
code=INVALID_REQUEST,
@@ -263,7 +263,7 @@ async def test_a_request_state_the_server_did_not_mint_is_rejected(caplog: pytes
"""The wire message is deliberately frozen; the real reason goes only to the server log."""
async with Client(tutorial001.mcp) as client:
with caplog.at_level(logging.WARNING, logger="mcp.server.request_state"):
- with pytest.raises(MCPError) as exc_info:
+ with pytest.raises(MCPError) as exc_info: # pragma: no branch
await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a")
assert exc_info.value.error == ErrorData(
code=INVALID_PARAMS, message="Invalid or expired requestState", data={"reason": "invalid_request_state"}
From 2996a2988daadd83d6e73e5842a802a7fc2afba6 Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 18:52:16 +0000
Subject: [PATCH 13/15] docs: replace every em-dash with dash-free grammar
By maintainer request: no em-dashes or other typographic non-ASCII in
the PR's prose, and each removal must restructure the sentence rather
than substitute a character. The PR's added lines carried 124 of them,
all in the four new pages, the three new section indexes, and the
sentences this PR rewrote on existing pages.
Each one was rewritten by reading the sentence it was in. A paired
aside became parentheses or its own sentence; a dash before an
elaboration became a colon or a new sentence; a dash gluing on a
reason or a consequence became "because" or "so" or a full stop; the
"title -- definition" bullets on the section indexes gained real verbs.
No meaning, link, emphasis, or code changed, and pre-existing prose on
pages this PR merely edits is untouched.
Every line the PR adds is now pure ASCII; the commit was gated on
grepping the whole added-line diff for non-ASCII and finding nothing.
---
AGENTS.md | 2 +-
docs/advanced/index.md | 19 ++++++++-------
docs/client/callbacks.md | 2 +-
docs/client/oauth-clients.md | 2 +-
docs/client/transports.md | 2 +-
docs/get-started/first-steps.md | 4 ++--
docs/get-started/index.md | 6 ++---
docs/get-started/real-host.md | 22 ++++++++---------
docs/get-started/testing.md | 4 ++--
docs/handlers/context.md | 2 +-
docs/handlers/elicitation.md | 2 +-
docs/handlers/index.md | 16 ++++++-------
docs/handlers/logging.md | 2 +-
docs/migration.md | 4 ++--
docs/run/asgi.md | 4 ++--
docs/run/authorization.md | 2 +-
docs/run/deploy.md | 42 ++++++++++++++++-----------------
docs/run/index.md | 4 ++--
docs/run/legacy-clients.md | 24 +++++++++----------
docs/servers/index.md | 10 ++++----
docs/servers/media.md | 2 +-
docs/troubleshooting.md | 12 +++++-----
22 files changed, 95 insertions(+), 94 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index bc764b9cc..43fbb887d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -140,7 +140,7 @@ rather than adding new standalone sections.
When a change affects public API or user-visible behaviour, update the relevant
page(s) under `docs/` in the same PR. Docs are organised by the `nav:` sections
in `mkdocs.yml` (Get started, Servers, Inside your handler, Running your server,
-Clients, Advanced), not by the on-disk directory names — find the page covering
+Clients, Advanced), not by the on-disk directory names. Find the page covering
the feature you touched in `mkdocs.yml` rather than adding a new one.
## Formatting & Type Checking
diff --git a/docs/advanced/index.md b/docs/advanced/index.md
index 7e9ed6f97..92af6d178 100644
--- a/docs/advanced/index.md
+++ b/docs/advanced/index.md
@@ -1,26 +1,27 @@
# Advanced
Everything an ordinary server or client needs has a topical home in the sections above.
-This section is the escape hatches — the things you reach for when `MCPServer`'s
-convenience layer is in the way:
+This section is the escape hatches you reach for when `MCPServer`'s convenience
+layer is in the way:
-* **[The low-level Server](low-level-server.md)** — the class `MCPServer` is built on.
+* **[The low-level Server](low-level-server.md)**: the class `MCPServer` is built on.
Hand-written schemas, `on_*` handlers, nothing checked for you, and custom JSON-RPC
methods of your own.
-* **[Pagination](pagination.md)** and **[Middleware](middleware.md)** — two things you
+* **[Pagination](pagination.md)** and **[Middleware](middleware.md)**: two things you
can *only* do on the low-level `Server`.
-* **[Extensions](extensions.md)** and **[MCP Apps](apps.md)** — the protocol's
- extension surface: compose extension packages into a server, or write your own.
+* **[Extensions](extensions.md)** and **[MCP Apps](apps.md)**: the protocol's
+ extension surface. Compose extension packages into a server, or write your own.
A few things you might reasonably look for here live where you'd actually use them
instead:
-* **Authorization** is under **[Running your server](../run/index.md)** — you protect a
- server where you deploy it.
+* **Authorization** is under **[Running your server](../run/index.md)** because you
+ protect a server where you deploy it.
* **OAuth**, **identity assertion**, connecting to **multiple servers**, and the
response **cache** are all under **[Clients](../client/index.md)**.
* **Multi-round-trip requests** and **Subscriptions** are under
- **[Inside your handler](../handlers/index.md)** — both are things a handler *does*.
+ **[Inside your handler](../handlers/index.md)** because both are things a
+ handler *does*.
* **URI templates** is under **[Servers](../servers/index.md)**, next to Resources.
* **[Protocol versions](../protocol-versions.md)** and
**[Deprecated features](../deprecated.md)** each have their own top-level page.
diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md
index e3ef721e2..e9787da8d 100644
--- a/docs/client/callbacks.md
+++ b/docs/client/callbacks.md
@@ -144,4 +144,4 @@ Two more. Neither declares anything.
* `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead.
* `logging_callback` and `message_handler` receive notifications. They declare nothing.
-The first argument to `Client(...)` is a transport object — **[Client transports](transports.md)** covers every kind.
+The first argument to `Client(...)` is a transport object. **[Client transports](transports.md)** covers every kind.
diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md
index c52ea8fd7..0b5953596 100644
--- a/docs/client/oauth-clients.md
+++ b/docs/client/oauth-clients.md
@@ -95,7 +95,7 @@ The repository ships the live version. `examples/servers/simple-auth/` runs a st
The 2026-07-28 revision of the spec deprecates dynamic client registration in favor of **Client ID Metadata Documents** (CIMD). Instead of POSTing a fresh registration to every authorization server it meets, your client publishes one JSON document about itself at a stable HTTPS URL, and that URL *is* its `client_id`. The authorization server fetches the document; the provider never touches it.
-The SDK already speaks it: pass the URL as `client_metadata_url=` when you construct the provider. When the authorization server's metadata advertises `client_id_metadata_document_supported: true`, the provider skips the `/register` request entirely — the URL goes into the flow as the `client_id`, and there is no `client_secret`. When the server doesn't advertise it (most don't yet), or you never pass a URL, the provider falls back to dynamic registration **silently**, and everything above works exactly as described. Stored `client_info` still wins over both.
+The SDK already speaks it: pass the URL as `client_metadata_url=` when you construct the provider. When the authorization server's metadata advertises `client_id_metadata_document_supported: true`, the provider skips the `/register` request entirely: the URL goes into the flow as the `client_id`, and there is no `client_secret`. When the server doesn't advertise it (most don't yet), or you never pass a URL, the provider falls back to dynamic registration **silently**, and everything above works exactly as described. Stored `client_info` still wins over both.
The URL must be HTTPS with a non-root path; anything else is a `ValueError` at construction, before any network happens. The shipped `examples/clients/simple-auth-client/` takes it as the `MCP_CLIENT_METADATA_URL` environment variable.
diff --git a/docs/client/transports.md b/docs/client/transports.md
index ef51f7ee5..1587a1a7d 100644
--- a/docs/client/transports.md
+++ b/docs/client/transports.md
@@ -72,7 +72,7 @@ Two things to notice:
## stdio
-A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine — a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file.
+A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine: a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file.
Describe the process with `StdioServerParameters`, turn it into a transport with `stdio_client`, and hand *that* to `Client`:
diff --git a/docs/get-started/first-steps.md b/docs/get-started/first-steps.md
index cb9d6d857..ad2f4c63f 100644
--- a/docs/get-started/first-steps.md
+++ b/docs/get-started/first-steps.md
@@ -100,7 +100,7 @@ asyncio.run(main())
{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}}
```
-That dictionary is your server's declared **capabilities** — the first thing every connecting client learns:
+That dictionary is your server's declared **capabilities**. It's the first thing every connecting client learns:
| Capability | The client may now call |
|-------------|------------------------------------------------------------|
@@ -136,4 +136,4 @@ That ratio is the whole point of the SDK.
* The server's **capabilities** are declared for you, and a client only asks for what a server declares.
* `Client(mcp)` connects to the server object in memory: your test harness from day one.
-Next: **[Connect to a real host](real-host.md)** — this server inside Claude Desktop or an IDE, for real. Then **[Testing](testing.md)**: one page, one in-memory client, and you're never guessing whether it works. After that, each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**.
+Next up is **[Connect to a real host](real-host.md)**: this server inside Claude Desktop or an IDE, for real. Then **[Testing](testing.md)**: one page, one in-memory client, and you're never guessing whether it works. After that, each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**.
diff --git a/docs/get-started/index.md b/docs/get-started/index.md
index 262a879d5..6a317692b 100644
--- a/docs/get-started/index.md
+++ b/docs/get-started/index.md
@@ -44,9 +44,9 @@ You'll use this yourself in [Testing](testing.md); it's how you test your own se
## Where to go next
Once you have a server running, the rest of these docs are a reference, not a course.
-Every page stands on its own — jump straight to what you need:
+Every page stands on its own, so jump straight to what you need:
-* What a server exposes — tools, resources, prompts — is **[Servers](../servers/index.md)**.
+* What a server exposes (tools, resources, prompts) is **[Servers](../servers/index.md)**.
* What's available inside the functions you register is **[Inside your handler](../handlers/index.md)**.
-* Getting it in front of clients — stdio, HTTP, your existing FastAPI app — is **[Running your server](../run/index.md)**.
+* Getting it in front of clients (stdio, HTTP, your existing FastAPI app) is **[Running your server](../run/index.md)**.
* Building the other side, an application that *uses* MCP servers, is **[Clients](../client/index.md)**.
diff --git a/docs/get-started/real-host.md b/docs/get-started/real-host.md
index db375c1a7..159c1f56f 100644
--- a/docs/get-started/real-host.md
+++ b/docs/get-started/real-host.md
@@ -2,7 +2,7 @@
A **host** is the application your server ends up inside: Claude Desktop, Claude Code, an IDE. The host is what the user talks to. Inside it, an MCP **client** launches your server as a child process and speaks to it over that process's stdin and stdout.
-Which means connecting to a host is one act: you tell it **the command that starts your server**. Everything on this page — two CLI commands, three JSON files — is a different place to put that same command.
+Which means connecting to a host is one act: you tell it **the command that starts your server**. Everything on this page (two CLI commands, three JSON files) is a different place to put that same command.
## One server, every host
@@ -12,7 +12,7 @@ Which means connecting to a host is one act: you tell it **the command that star
Two tools and a resource, one file. Three things about that file matter to every host below:
-* `mcp.run()` with no arguments starts a **stdio** server: it blocks, reads protocol messages on stdin, and writes them on stdout. That is the transport every host on this page speaks — the host starts your file as a child process and owns those two pipes — and it is why connecting is only ever "here is the command". You never pick a port, and nothing listens on one.
+* `mcp.run()` with no arguments starts a **stdio** server: it blocks, reads protocol messages on stdin, and writes them on stdout. That is the transport every host on this page speaks. The host starts your file as a child process and owns those two pipes, which is why connecting is only ever "here is the command". You never pick a port, and nothing listens on one.
* `run()` is under `if __name__ == "__main__":`. Everything below **imports** this file rather than executing it, so an unguarded `run()` would start a server the moment anything loaded the module.
* The server object is a module-level global named `mcp`. That's the name `mcp run` looks for (`server` and `app` also work). Call it something else and you name it explicitly: `mcp run server.py:bookshop`.
@@ -26,7 +26,7 @@ Every host below gets the same command:
uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
```
-One command for all of them because `uv run --with` resolves the pinned SDK into a fresh environment on the spot: it works from any directory, needs no project and no virtual environment to activate, and always gets the exact `mcp` version these docs describe. That matters here more than anywhere else, because a host launches your server from *its* working directory with a near-empty environment — not from your shell.
+One command for all of them because `uv run --with` resolves the pinned SDK into a fresh environment on the spot: it works from any directory, needs no project and no virtual environment to activate, and always gets the exact `mcp` version these docs describe. That matters here more than anywhere else, because a host launches your server from *its* working directory with a near-empty environment, not from your shell.
It is also the command `mcp install` writes into Claude Desktop's config for you (below), so what you type by hand and what the tool generates agree.
@@ -43,7 +43,7 @@ It is also the command `mcp install` writes into Claude Desktop's config for you
!!! note "This page is the local story"
Everything here runs your server on the machine the host is on: the host launches your
file, over stdio. That is exactly right for a personal or single-machine tool. To give a
- server to people who do *not* have your file, you hand out a **URL**, not a command — the
+ server to people who do *not* have your file, you hand out a **URL**, not a command: the
same `mcp` object served over Streamable HTTP. **[Running your server](../run/index.md)**
is that decision in one table, and **[Deploy & scale](../run/deploy.md)** is the road from
there to a real hostname.
@@ -61,7 +61,7 @@ The one host the SDK can configure for you:
uv run mcp install server.py
```
-That's it. `mcp install` imports the file to read the server's name, finds Claude Desktop's config file, and writes the launch command into it — converting your path to an absolute one on the way, so you don't have to.
+That's it. `mcp install` imports the file to read the server's name, finds Claude Desktop's config file, and writes the launch command into it. Along the way it converts your path to an absolute one, so you don't have to.
There is nothing to be mystified by. This is the entry it writes:
@@ -91,11 +91,11 @@ That's the launch command from the section above with two additions: the absolut
You can write that file by hand. `mcp install` exists so you don't make the two classic mistakes (a relative path, a missing version pin) while doing it.
-Fully quit Claude Desktop — not just its window — and reopen it.
+Fully quit Claude Desktop (not just its window) and reopen it.
!!! warning
`mcp install` fails with `Claude app not found` if Claude Desktop's config *directory* doesn't
- exist yet. Install Claude Desktop and run it once — that's what creates the directory.
+ exist yet. Install Claude Desktop and run it once: that's what creates the directory.
!!! tip
Claude Desktop starts your server in its own process, so your shell's environment variables are
@@ -149,7 +149,7 @@ Two differences from Cursor's file, and they are the only two: the wrapper key i
!!! note
You need VS Code 1.99 or later with the **GitHub Copilot** extension signed in (Copilot Free is
- enough), and Copilot Chat must be in **Agent** mode — the only mode that calls tools.
+ enough), and Copilot Chat must be in **Agent** mode, because no other mode calls tools.
## It doesn't show up
@@ -164,8 +164,8 @@ Nothing prints, and it doesn't return. That silence is correct: a stdio server i
Once that command sits and waits, what's left is almost always one of three things:
* **A relative path.** The host launches your server from *its* working directory, not the one you registered from. `server.py` where `/absolute/path/to/server.py` is needed is the single most common failure. If the host can't find `uv` either, that path has to be absolute too.
-* **The host is still running its old config.** Hosts read their config at launch. Claude Desktop in particular has to be *fully quit* — not just its window closed — and reopened before an edit to `claude_desktop_config.json` takes effect.
-* **Something reached stdout.** On stdio, stdout *is* the protocol. One stray `print()` and the host reads a corrupt message and drops the connection. Log with the `logging` module — it writes to stderr. **[Logging](../handlers/logging.md)** has the whole story.
+* **The host is still running its old config.** Hosts read their config at launch. Claude Desktop in particular has to be *fully quit* (not just its window closed) and reopened before an edit to `claude_desktop_config.json` takes effect.
+* **Something reached stdout.** On stdio, stdout *is* the protocol. One stray `print()` and the host reads a corrupt message and drops the connection. Log with the `logging` module, which writes to stderr. **[Logging](../handlers/logging.md)** has the whole story.
Claude Desktop keeps a log per server: `mcp-server-.log` is your server's stderr, next to `mcp.log` for connections, under `~/Library/Logs/Claude` on macOS and `%APPDATA%\Claude\logs` on Windows.
@@ -175,7 +175,7 @@ For anything past those three, **[Troubleshooting](../troubleshooting.md)** is t
* A **host** (Claude Desktop, an IDE) runs an MCP client that launches your server as a child process over stdio. Connecting means giving it one launch command.
* That command is `uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py`: version-pinned, no venv to activate, works from any directory. The pin is mandatory while v2 is in beta.
-* **Claude Desktop** is the one host `mcp install` configures for you. It writes that same command — plus the absolute path to `uv` — into `claude_desktop_config.json`, so you never have to.
+* **Claude Desktop** is the one host `mcp install` configures for you. It writes that same command (plus the absolute path to `uv`) into `claude_desktop_config.json`, so you never have to.
* **Claude Code** is `claude mcp add bookshop -- `. **Cursor** is `.cursor/mcp.json` under `mcpServers`. **VS Code** is `.vscode/mcp.json` under `servers`, each entry with a `type`.
* Absolute paths everywhere, restart the host after editing its config, and never let anything but the SDK write to stdout.
diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md
index 3feceffb0..68b3c443e 100644
--- a/docs/get-started/testing.md
+++ b/docs/get-started/testing.md
@@ -102,6 +102,6 @@ That one line is also why these docs can promise you that their examples work: e
example file is exercised by the SDK's own test suite through exactly this client. You're using the
same tool the SDK uses on itself.
-You have a working, tested server. Putting it inside a real application — Claude Desktop, an
-IDE — is **[Connect to a real host](real-host.md)**; every other way to serve it is
+You have a working, tested server. Putting it inside a real application (Claude Desktop, an
+IDE) is **[Connect to a real host](real-host.md)**; every other way to serve it is
**[Running your server](../run/index.md)**.
diff --git a/docs/handlers/context.md b/docs/handlers/context.md
index a6088d372..f43521aa0 100644
--- a/docs/handlers/context.md
+++ b/docs/handlers/context.md
@@ -104,7 +104,7 @@ What a server offers is not fixed at import time. Register a tool at runtime, th
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 — the `send_*` methods above do not reach those streams. The `Context` publish methods — `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, and `await ctx.notify_resource_updated(uri)` — deliver to every subscribed stream at once. The whole story, including scaling out across replicas, is in **[Subscriptions](subscriptions.md)**.
+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
diff --git a/docs/handlers/elicitation.md b/docs/handlers/elicitation.md
index 9bc5d52e4..3f3f5a6c0 100644
--- a/docs/handlers/elicitation.md
+++ b/docs/handlers/elicitation.md
@@ -182,4 +182,4 @@ Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit
* 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)**.
+Everything underneath that return (the retry loop, protecting `requestState`, driving it yourself) is **[Multi-round-trip requests](multi-round-trip.md)**.
diff --git a/docs/handlers/index.md b/docs/handlers/index.md
index cb1ef5535..eb2b5be41 100644
--- a/docs/handlers/index.md
+++ b/docs/handlers/index.md
@@ -5,24 +5,24 @@ everything it can do while it runs, is here.
What it can read:
-* **[The Context](context.md)** — the one extra parameter any handler can
+* **[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)** — parameters the model never sees,
+* **[Dependencies](dependencies.md)** are parameters the model never sees,
filled in by your own functions with `Resolve`.
-* **[Lifespan](lifespan.md)** — state your server builds once at startup,
- and how a handler reaches it through the `Context`.
+* **[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 — **[Elicitation](elicitation.md)**, and
+* 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.
* Report **[Progress](progress.md)** on something slow.
-* Write logs — to standard error, for whoever operates the server — with
+* Write logs (to standard error, for whoever operates the server) with
**[Logging](logging.md)**.
-* Tell subscribed clients that something changed —
+* 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.
+**[Tools](../servers/tools.md)**. Every page here assumes you have one.
diff --git a/docs/handlers/logging.md b/docs/handlers/logging.md
index bd34ec3a9..945aa60d5 100644
--- a/docs/handlers/logging.md
+++ b/docs/handlers/logging.md
@@ -75,4 +75,4 @@ went to standard error: the terminal, not the wire.
* 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)**.
+Telling connected clients that something on your server changed (the tool list, a resource) is **[Subscriptions](subscriptions.md)**.
diff --git a/docs/migration.md b/docs/migration.md
index e52b0c1d1..186f3d40e 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -1512,7 +1512,7 @@ Behavior changes:
Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions.
-The 2026-07-28 revision reintroduces Tasks as an official extension — [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet.
+The 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet.
## Deprecations
@@ -1665,7 +1665,7 @@ The implementation is responsible for validating the assertion per RFC 7523 §3
### 2025-11-25 and 2026-07-28 protocol fields modeled
-`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. Servers set per-method values with `cache_hints={method: CacheHint(...)}` on the `Server`/`MCPServer` constructor — see [Caching hints](client/caching.md).
+`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. Servers set per-method values with `cache_hints={method: CacheHint(...)}` on the `Server`/`MCPServer` constructor. See [Caching hints](client/caching.md) for details.
### `streamable_http_app()` available on lowlevel Server
diff --git a/docs/run/asgi.md b/docs/run/asgi.md
index 8928fa217..858c93b60 100644
--- a/docs/run/asgi.md
+++ b/docs/run/asgi.md
@@ -41,7 +41,7 @@ Out of the box the app answers **only** requests addressed to localhost. `stream
cannot know which hostname it will be served behind, so it arms DNS-rebinding protection with the
safest possible allowlist; on your machine that is exactly right. Deployed behind a real hostname,
it means **every request is rejected with `421 Misdirected Request`** until you pass
-`transport_security=` an allowlist of what you actually serve — and nothing you built is even
+`transport_security=` an allowlist of what you actually serve. Nothing you built is even
consulted first. That allowlist, and everything else between a working app and a real hostname,
is **[Deploy & scale](deploy.md)**.
@@ -57,7 +57,7 @@ The moment the MCP server is *part* of a bigger application, you put the app ins
* The `lifespan` function enters `mcp.session_manager.run()` for the lifetime of the **host** app. This is the line everyone forgets.
* `mcp.session_manager` only exists *after* `streamable_http_app()` has been called. That is why the routes are built at module level and the manager is only touched inside the lifespan.
-Starlette's `Host` route works the same way: swap `Mount("/", ...)` for `Host("mcp.example.com", ...)` to route by hostname instead of by path. The lifespan rule does not change, and neither does the transport-security one. A `Host("mcp.example.com", ...)` route only ever receives requests addressed to that hostname, but the transport's own Host allowlist (**[Deploy & scale](deploy.md)**) still runs first — without `"mcp.example.com"` in it, that route answers every one of them with a `421`.
+Starlette's `Host` route works the same way: swap `Mount("/", ...)` for `Host("mcp.example.com", ...)` to route by hostname instead of by path. The lifespan rule does not change, and neither does the transport-security one. A `Host("mcp.example.com", ...)` route only ever receives requests addressed to that hostname, but the transport's own Host allowlist (**[Deploy & scale](deploy.md)**) still runs first. Without `"mcp.example.com"` in it, that route answers every one of them with a `421`.
!!! warning "The host app owns the lifespan"
`streamable_http_app()` wires `session_manager.run()` into the lifespan of the Starlette it
diff --git a/docs/run/authorization.md b/docs/run/authorization.md
index 1236da3ff..b7d731b1e 100644
--- a/docs/run/authorization.md
+++ b/docs/run/authorization.md
@@ -122,4 +122,4 @@ An authorization server can also accept an enterprise identity provider's signed
* `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 client half — discovering your authorization server and fetching the token for you — is **[OAuth clients](../client/oauth-clients.md)**. And a client that *asserts* an identity instead of asking a user for one is **[Identity assertion](../client/identity-assertion.md)**.
+The client half (discovering your authorization server and fetching the token for you) is **[OAuth clients](../client/oauth-clients.md)**. And a client that *asserts* an identity instead of asking a user for one is **[Identity assertion](../client/identity-assertion.md)**.
diff --git a/docs/run/deploy.md b/docs/run/deploy.md
index 0d73a8f81..cad564c42 100644
--- a/docs/run/deploy.md
+++ b/docs/run/deploy.md
@@ -6,7 +6,7 @@ Almost none of that is MCP's business. You bring the ASGI server, the process ma
## Before anything else: the Host allowlist
-`streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding protection** and accepts a request only if its `Host` header is `127.0.0.1:`, `localhost:`, or `[::1]:` — and only if its `Origin` header, when there is one, is the `http://` form of the same. On your machine that is exactly right: it stops a malicious web page from driving your local server through a DNS name it rebound to `127.0.0.1`.
+`streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding protection** and accepts a request only if its `Host` header is `127.0.0.1:`, `localhost:`, or `[::1]:`. The `Origin` header, when there is one, has to be the `http://` form of the same. On your machine that is exactly right: it stops a malicious web page from driving your local server through a DNS name it rebound to `127.0.0.1`.
Deployed behind a real hostname, that same default rejects **every request** until you say otherwise. The check runs before anything MCP-shaped does, so nothing you built is even consulted:
@@ -22,13 +22,13 @@ Deployed behind a real hostname, that same default rejects **every request** unt
```
* `allowed_hosts` entries are exact strings: `"mcp.example.com"` matches a bare `Host` header and `"mcp.example.com:*"` matches any port. List both.
-* `allowed_origins` only matters for browsers — nothing else sends `Origin`. It is the server-side twin of the CORS configuration in **[Add to an existing app](asgi.md)**.
+* `allowed_origins` only matters for browsers, because nothing else sends `Origin`. It is the server-side twin of the CORS configuration in **[Add to an existing app](asgi.md)**.
* Behind a reverse proxy that already controls the `Host` header, switching the check off is the honest configuration: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`.
* Passing a non-localhost `host=` (for example `host="mcp.example.com"`) does **not** allowlist that hostname. It only stops the localhost default from arming the protection, which leaves every Host and Origin accepted. Say what you mean with `transport_security=` instead.
!!! check
Delete the `transport_security=security` argument and deploy the app anyway. It starts, `/mcp`
- routes, and every request — including from a plain `curl` — comes back:
+ routes, and every request (including from a plain `curl`) comes back:
```text
HTTP/1.1 421 Misdirected Request
@@ -39,7 +39,7 @@ Deployed behind a real hostname, that same default rejects **every request** unt
You will not find those words on the client side. A `421` is a plain-text HTTP response, not a
JSON-RPC error, so the MCP client raises a generic transport error; the hostname it
didn't like appears only in the **server's** log, as a single warning. A freshly
- deployed server that refuses every connection is a Host allowlist until proven otherwise —
+ deployed server that refuses every connection is a Host allowlist until proven otherwise.
**[Troubleshooting](../troubleshooting.md)** starts here too.
## Workers, and who has to be sticky
@@ -54,7 +54,7 @@ Four processes, one socket. And now the question every deployment has to answer:
For a client speaking the **2026-07-28** protocol, no. A modern request is one self-contained POST: no `initialize` handshake before it, no `Mcp-Session-Id` on the response, nothing for a second request to come back *to*. Route it to any worker.
-That is not a mode you switch on. `stateless_http=True` looks like it should be, but the transport routes on the `MCP-Protocol-Version` request header, hands a modern request to the modern handler, and **returns** — the line that reads `stateless_http` comes *after* that return. It isn't that the flag is ignored on the 2026-07-28 path; it is never reached. `stateless_http` is a knob for the **legacy** leg only, and the modern path is sessionless by construction.
+That is not a mode you switch on. `stateless_http=True` looks like it should be, but the transport routes on the `MCP-Protocol-Version` request header, hands a modern request to the modern handler, and **returns**. The line that reads `stateless_http` comes *after* that return. It isn't that the flag is ignored on the 2026-07-28 path; it is never reached. `stateless_http` is a knob for the **legacy** leg only, and the modern path is sessionless by construction.
For a legacy client on spec version 2025-11-25 or earlier, the answer depends on that flag:
@@ -62,7 +62,7 @@ For a legacy client on spec version 2025-11-25 or earlier, the answer depends on
| --- | --- | --- |
| **2026-07-28** | None. `Mcp-Session-Id` is never set. | Nothing. Any worker serves any request. |
| **2025-11-25 and earlier** (the default) | `Mcp-Session-Id`, held in one worker's memory. | **Sticky sessions.** A follow-up that reaches a different worker gets a `404` *"Session not found"*. |
-| **2025-11-25 and earlier**, with `stateless_http=True` | None. | Nothing — at the cost of the server-to-client back-channel (sampling, push elicitation, `roots/list`) and of resumability. |
+| **2025-11-25 and earlier**, with `stateless_http=True` | None. | Nothing. The cost is the server-to-client back-channel (sampling, push elicitation, `roots/list`) and resumability. |
Sticky sessions and what the legacy leg costs are their own page, **[Serving legacy clients](legacy-clients.md)**; the two eras themselves are **[Protocol versions](../protocol-versions.md)**. What matters here is the shape of the answer: *on 2026-07-28 you are already stateless, with nothing to configure.*
@@ -70,7 +70,7 @@ The rest of this page is the two things that being stateless does **not** buy yo
## `requestState` across workers
-A **[multi-round-trip](../handlers/multi-round-trip.md)** tool needs something the client has to go get — a confirmation, a choice, a credential — so it returns a question instead of an answer and finishes on the retry. Between the two rounds the client holds an opaque `request_state` token the server minted. On the retry the server has to open that token again.
+A **[multi-round-trip](../handlers/multi-round-trip.md)** tool needs something the client has to go get (a confirmation, a choice, a credential), so it returns a question instead of an answer and finishes on the retry. Between the two rounds the client holds an opaque `request_state` token the server minted. On the retry the server has to open that token again.
*Sealed under what key?* By default, one the server generated with `os.urandom(32)` at construction time. Under `--workers 4` that is four constructions, in four processes: four different keys, never written anywhere, never shared, gone on restart.
@@ -80,7 +80,7 @@ Here is a tool that asks before it acts, on a server that configures nothing:
--8<-- "docs_src/deploy/tutorial002.py"
```
-The first round reaches worker A. Worker A seals `refund:120` under **its** key and returns the token. The client puts the question in front of a person, gets a yes, and retries — a brand-new HTTP request.
+The first round reaches worker A. Worker A seals `refund:120` under **its** key and returns the token. The client puts the question in front of a person, gets a yes, and retries. The retry is a brand-new HTTP request.
!!! check
Let that retry reach worker B. B tries to unseal a token it did not mint, cannot, and refuses the
@@ -94,8 +94,8 @@ The first round reaches worker A. Worker A seals `refund:120` under **its** key
}
```
- That message is **frozen**. Expired, tampered with, replayed against different arguments, or — by
- far the most common cause in a real deployment — sealed by a sibling worker: the client is told
+ That message is **frozen**. Expired, tampered with, replayed against different arguments, or (by
+ far the most common cause in a real deployment) sealed by a sibling worker: the client is told
the same thing every time, so the wire never reveals which check failed. The real reason is one
`WARNING` in the server's log:
@@ -115,8 +115,8 @@ The fix is one argument. It has **two** halves.
--8<-- "docs_src/deploy/tutorial003.py"
```
-* **`keys=[...]`** is the half everyone finds. Give every instance the same secret — at least 32 bytes of it — and every instance can unseal what any sibling minted. `keys[0]` seals and every key in the list unseals, which is the rotation ring; **[Rotating keys](../handlers/multi-round-trip.md#rotating-keys)** is how you turn it without downtime.
-* **The server's name** is the half almost nobody finds, and the reason cross-instance retries still fail after you share the key. Every sealed token carries the server's `name` as an **audience claim**, checked strictly on the way back in. Two instances built from the same code have the same name and never notice it. Name them apart — `MCPServer(f"billing-{POD}")` reads like good observability hygiene — and every cross-instance retry is refused exactly as above, shared key or not. The log says `audience` instead of `unknown key`; the client cannot tell the difference.
+* **`keys=[...]`** is the half everyone finds. Give every instance the same secret (at least 32 bytes of it), and every instance can unseal what any sibling minted. `keys[0]` seals and every key in the list unseals, which is the rotation ring; **[Rotating keys](../handlers/multi-round-trip.md#rotating-keys)** is how you turn it without downtime.
+* **The server's name** is the half almost nobody finds, and the reason cross-instance retries still fail after you share the key. Every sealed token carries the server's `name` as an **audience claim**, checked strictly on the way back in. Two instances built from the same code have the same name and never notice it. Name them apart (`MCPServer(f"billing-{POD}")` reads like good observability hygiene), and every cross-instance retry is refused exactly as above, shared key or not. The log says `audience` instead of `unknown key`; the client cannot tell the difference.
Mint the secret once and hand the same value to every instance. This is the command the SDK's own error message tells you to run if you pass it fewer than 32 bytes:
@@ -126,10 +126,10 @@ python -c "import secrets; print(secrets.token_hex(32))"
!!! warning "Same keys, *and* the same name"
A multi-instance deployment must share both. If per-instance names are load-bearing for you,
- give the fleet one explicit audience instead — `RequestStateSecurity(keys=[...], audience="billing")` —
- and every instance mints and accepts under `"billing"` no matter what it is called.
+ give the fleet one explicit audience instead: `RequestStateSecurity(keys=[...], audience="billing")`.
+ Every instance then mints and accepts under `"billing"` no matter what it is called.
-Everything else about the seal — what it binds, the per-round `ttl` (600 seconds by default), bringing your own codec, why the unconfigured default is exactly right on `stdio` — is **[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**. This page's whole contribution is a two-item checklist: *same keys, same name.*
+Everything else about the seal is **[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**: what it binds, the per-round `ttl` (600 seconds by default), bringing your own codec, why the unconfigured default is exactly right on `stdio`. This page's whole contribution is a two-item checklist: *same keys, same name.*
!!! info
You are on this path even if you have never typed `InputRequiredResult`. A tool whose parameters
@@ -149,25 +149,25 @@ The seam between the two is the `SubscriptionBus`. Whatever bus you give a serve
Nothing about the fan-out cares which server object a stream is attached to. Two servers holding one `InMemorySubscriptionBus` already behave this way: open a listen stream on one, `edit_note` on the other, and the stream hears about it. That in-memory bus only spans server objects inside one process, which makes it the model, not the deployment:
-* Across real processes, **the SDK ships no bus that can help you.** `SubscriptionBus` is a two-method `Protocol` — `publish` and `subscribe` — that you implement over your own pub/sub backend (Redis, NATS, whatever you already run) and pass as `MCPServer(subscriptions=...)`. **[Subscriptions](../handlers/subscriptions.md#one-process-is-the-default-more-takes-a-bus)** has the sketch and the contract.
+* Across real processes, **the SDK ships no bus that can help you.** `SubscriptionBus` is a two-method `Protocol` (`publish` and `subscribe`) that you implement over your own pub/sub backend (Redis, NATS, whatever you already run) and pass as `MCPServer(subscriptions=...)`. **[Subscriptions](../handlers/subscriptions.md#one-process-is-the-default-more-takes-a-bus)** has the sketch and the contract.
* The bus carries four small typed events, never JSON-RPC. Acknowledgment, filtering, and stream lifecycle stay in the SDK, so your bus cannot break the protocol; it can only move events between processes.
-* Streams are **not** resumable and events are **not** replayed. Losing a replica drops its streams; the clients re-listen and re-fetch. There is no event store to share and nothing else to configure — this is the one place where scaling out is genuinely just more of the same.
+* Streams are **not** resumable and events are **not** replayed. Losing a replica drops its streams; the clients re-listen and re-fetch. There is no event store to share and nothing else to configure. This is the one place where scaling out is genuinely just more of the same.
## What the SDK does not give you
An `MCPServer` is a protocol implementation, not an application server. The deployment knobs you go looking for next are missing on purpose:
-* **No `workers=`.** `mcp.run("streamable-http")` starts exactly one uvicorn process, and that is all it will ever start. Multi-process is `streamable_http_app()` handed to whatever you already deploy ASGI with — `uvicorn --workers`, gunicorn, your platform's process manager. This page is deliberately not a tutorial for any of them; their documentation is better than a copy of it here would be.
-* **No health-check route.** `@mcp.custom_route("/health", methods=["GET"])` is the whole answer, and it is never authenticated even when the rest of the server is — right for a liveness probe, wrong for anything private. **[Add to an existing app](asgi.md#custom-routes)** shows one.
+* **No `workers=`.** `mcp.run("streamable-http")` starts exactly one uvicorn process, and that is all it will ever start. Multi-process is `streamable_http_app()` handed to whatever you already deploy ASGI with: `uvicorn --workers`, gunicorn, your platform's process manager. This page is deliberately not a tutorial for any of them; their documentation is better than a copy of it here would be.
+* **No health-check route.** `@mcp.custom_route("/health", methods=["GET"])` is the whole answer, and it is never authenticated even when the rest of the server is. That is right for a liveness probe, wrong for anything private. **[Add to an existing app](asgi.md#custom-routes)** shows one.
* **No production settings object.** There is nowhere on `MCPServer` to write down timeouts, TLS, graceful shutdown, or connection limits, because none of those are its job. They belong to your ASGI server, and you configure them there. **[Running your server](index.md)** covers the handful of settings the constructor *does* take.
* **No shipped `EventStore`, and on 2026-07-28 no use for one.** Resumability is a feature of the legacy stateful leg; a modern exchange is one POST, one response, and nothing to resume.
## Recap
* Out of the box the app answers only requests addressed to localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` is the go-live gate: until you pass it, every request behind a real hostname is a `421` and the reason is only in the server's log.
-* On 2026-07-28 there is no session and nothing for a load balancer to be sticky on. `stateless_http=True` is a legacy-only knob — a modern request is routed and answered before that flag is ever read.
+* On 2026-07-28 there is no session and nothing for a load balancer to be sticky on. `stateless_http=True` is a legacy-only knob because a modern request is routed and answered before that flag is ever read.
* The default `requestState` key is `os.urandom(32)`, minted per process. A multi-round-trip retry that reaches a different worker fails with `-32602` *"Invalid or expired requestState"*.
-* The fix is `RequestStateSecurity(keys=[...])` **and** the same server name on every instance — the name is the token's default audience claim. Same keys, same name.
+* The fix is `RequestStateSecurity(keys=[...])` **and** the same server name on every instance. The name is the token's default audience claim. Same keys, same name.
* Change notifications cross replicas through one shared `SubscriptionBus`. The SDK's only implementation is in-process; the two-method `Protocol` over your own pub/sub is yours to write.
* There is no `workers=`, no health route, no production settings object. Bring your own ASGI server.
diff --git a/docs/run/index.md b/docs/run/index.md
index cd7dc2954..b3cea554c 100644
--- a/docs/run/index.md
+++ b/docs/run/index.md
@@ -127,7 +127,7 @@ uv run mcp install server.py -v API_KEY=abc123 -f .env
`-v KEY=VALUE` and `-f .env` record environment variables in that entry. Claude Desktop starts your server in its own process. Your shell's environment is not there.
-Claude Desktop is the only host `mcp install` knows. Every other host — Claude Code, Cursor, VS Code — takes the same launch command in its own config file, and **[Connect to a real host](../get-started/real-host.md)** has each one.
+Claude Desktop is the only host `mcp install` knows. Every other host (Claude Code, Cursor, VS Code) takes the same launch command in its own config file, and **[Connect to a real host](../get-started/real-host.md)** has each one.
`mcp version` prints the installed SDK version.
@@ -145,4 +145,4 @@ Claude Desktop is the only host `mcp install` knows. Every other host — Claude
* `mcp dev` for the Inspector, `mcp run` to execute a file, `mcp install` for Claude Desktop, `mcp version` for the version.
* The transport never changes what your server *is*: all three files on this page expose the identical tool.
-When `run()` itself is the limit — your server inside an app that already exists — it is **[Add to an existing app](asgi.md)**. A real hostname and more than one worker is **[Deploy & scale](deploy.md)**. And if some of your clients are still on spec version 2025-11-25 or earlier, **[Serving legacy clients](legacy-clients.md)** is the good news.
+When `run()` itself is the limit (your server inside an app that already exists), it is **[Add to an existing app](asgi.md)**. A real hostname and more than one worker is **[Deploy & scale](deploy.md)**. And if some of your clients are still on spec version 2025-11-25 or earlier, **[Serving legacy clients](legacy-clients.md)** is the good news.
diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md
index 9e09d4363..c7a1096db 100644
--- a/docs/run/legacy-clients.md
+++ b/docs/run/legacy-clients.md
@@ -10,7 +10,7 @@ So a legacy client is not something you build *for*. It is something that connec
!!! note
Nothing, literally. There is no `legacy=` option, no version allowlist, no way to reject or
- disable an era — not on `streamable_http_app()`, not on `run()`, not on the session manager.
+ disable an era: not on `streamable_http_app()`, not on `run()`, not on the session manager.
Both eras are always on. The nearest thing to a per-era switch in that signature is
`stateless_http`, and it is most of this page.
@@ -24,7 +24,7 @@ Here is a tool that has to ask the user something, and both eras of client calli
`reserve` needs one thing the model didn't supply: how many copies. `Annotated[..., Resolve(ask_quantity)]` is how a tool declares that (**[Dependencies](../handlers/dependencies.md)** is that whole story). Nothing in `reserve` names a version, checks a capability, or branches.
-The two clients are open **at the same time**, on the same `mcp` object. `mode="legacy"` runs the `initialize` handshake — the exact connection a pre-2026 client opens. The other one takes the default and lands on `2026-07-28`.
+The two clients are open **at the same time**, on the same `mcp` object. `mode="legacy"` runs the `initialize` handshake: the exact connection a pre-2026 client opens. The other one takes the default and lands on `2026-07-28`.
```text
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
@@ -38,7 +38,7 @@ It is worth pausing on *how*, because the two clients were asked the same questi
!!! tip
That era-portability is *why* `Resolve` is the API to build on. Its older sibling `ctx.elicit()`
(**[Elicitation](../handlers/elicitation.md)**) only ever sends `elicitation/create`, so it only
- ever works on a legacy connection — on a `2026-07-28` one the call fails. If a tool still uses
+ ever works on a legacy connection. On a `2026-07-28` one the call fails. If a tool still uses
it, the fix is the one you see above, not a version check.
## What a legacy session costs you
@@ -49,11 +49,11 @@ A `2026-07-28` connection is **sessionless**: every request stands alone, and th
That record is a **plain in-process `dict`**. There is no distributed session store and no way to plug one in.
-On one worker that is invisible. On two, it is the whole problem: a request that carries an `Mcp-Session-Id` and lands on a worker that didn't mint it finds nothing in that dict, and the answer is a `404` — `Session not found` — not the tool result. So the moment you run more than one worker, **legacy clients need sticky routing**: every request in a session has to reach the process that started it. Modern clients never do; they have no session to be sticky to. **[Deploy & scale](deploy.md)** covers stickiness and everything else about running more than one of these.
+On one worker that is invisible. On two, it is the whole problem: a request that carries an `Mcp-Session-Id` and lands on a worker that didn't mint it finds nothing in that dict, and the answer is a `404` (`Session not found`), not the tool result. So the moment you run more than one worker, **legacy clients need sticky routing**: every request in a session has to reach the process that started it. Modern clients never do; they have no session to be sticky to. **[Deploy & scale](deploy.md)** covers stickiness and everything else about running more than one of these.
!!! warning
- `event_store=` looks like the fix and is not. It is **resumability** — replaying missed SSE
- events to a client reconnecting to the *same* session — not a session store. It never makes a
+ `event_store=` looks like the fix and is not. It is **resumability** (replaying missed SSE
+ events to a client reconnecting to the *same* session), not a session store. It never makes a
session reachable from another process.
## The one knob: `stateless_http`
@@ -70,13 +70,13 @@ Two things about it matter more than what it does.
**It only touches the legacy leg.** Requests are routed on the version header *before* `stateless_http` is read, so the modern path never sees it. A `2026-07-28` connection is already sessionless and is exactly the same under either value.
-**It costs both server-to-client channels on that leg.** A session that lives for one `POST` has no stream for the server to push a request down and no standalone stream for it to push notifications down. Every server-initiated request raises `NoBackChannelError`: `ctx.elicit()`, the retired sampling and roots calls (**[Deprecated features](../deprecated.md)**), and — yes — `Resolve` asking a *legacy* client its question. Notifications don't even get an error; they are silently dropped.
+**It costs both server-to-client channels on that leg.** A session that lives for one `POST` has no stream for the server to push a request down and no standalone stream for it to push notifications down. Every server-initiated request raises `NoBackChannelError`: `ctx.elicit()`, the retired sampling and roots calls (**[Deprecated features](../deprecated.md)**), and, yes, `Resolve` asking a *legacy* client its question. Notifications don't even get an error; they are silently dropped.
!!! check
Do the wrong thing. `reserve` is the exact tool that just served both clients. Deploy it with
`stateless_http=True`, connect the same two clients over HTTP, and call it from each.
- The modern client still gets `Reserved 2 of 'Dune'.` — the modern leg didn't change.
+ The modern client still gets `Reserved 2 of 'Dune'.` The modern leg didn't change.
The legacy client's call does not come back as an `is_error` result the model could read.
The whole request fails, as a top-level protocol error:
@@ -95,12 +95,12 @@ So it is a real trade, and it only exists on the legacy leg: **sessionful and st
Almost nowhere.
-Tools, resources, prompts, structured output, progress, errors: none of them care which era called. The `initialize` handshake, the `Mcp-Session-Id`, the standalone stream, the `DELETE` that ends a session — the SDK owns all of it, and a handler never sees any of it. Interactive input is *the* place the eras genuinely differ on the wire, and `Resolve` exists so that it is not your problem: you just watched one tool serve both.
+Tools, resources, prompts, structured output, progress, errors: none of them care which era called. The `initialize` handshake, the `Mcp-Session-Id`, the standalone stream, the `DELETE` that ends a session: the SDK owns all of it, and a handler never sees any of it. Interactive input is *the* place the eras genuinely differ on the wire, and `Resolve` exists so that it is not your problem: you just watched one tool serve both.
There is exactly one thing left, and it is **change notifications**, because the two eras listen on different pipes:
-* A `2026-07-28` client opens a `subscriptions/listen` stream and reads the subscriptions bus. `ctx.notify_resource_updated()` — and `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()` — publish there, and *only* there. **[Subscriptions](../handlers/subscriptions.md)** is that page.
-* A legacy client reads the standalone stream its session keeps open. `ctx.session.send_resource_updated()` — and `send_tool_list_changed()` and friends — write to the *connection* that carried the request: for a legacy session, that is its standalone stream. For a modern HTTP request there is no such channel, and the notification is quietly dropped.
+* A `2026-07-28` client opens a `subscriptions/listen` stream and reads the subscriptions bus. `ctx.notify_resource_updated()` (and `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) publish there, and *only* there. **[Subscriptions](../handlers/subscriptions.md)** is that page.
+* A legacy client reads the standalone stream its session keeps open. `ctx.session.send_resource_updated()` (and `send_tool_list_changed()` and friends) write to the *connection* that carried the request: for a legacy session, that is its standalone stream. For a modern HTTP request there is no such channel, and the notification is quietly dropped.
Over HTTP, neither call reaches the other era's clients. To tell everyone, call both:
@@ -117,4 +117,4 @@ Two lines, no `if`, no version check, and you are done. That is the entire list
* `stateless_http=True` is the one knob, and it is **legacy-leg-only**. It buys free load balancing for legacy clients at the price of both server-to-client channels on that leg: server-initiated requests raise `NoBackChannelError` (a top-level error at the client, not an `is_error` result), and notifications are dropped.
* A `2026-07-28` connection is sessionless either way. `stateless_http` never touches it.
* Your handler code forks on era in exactly one place: change notifications. `ctx.notify_*` reaches `subscriptions/listen` clients; `ctx.session.send_*` reaches legacy sessions. Call both.
-* Everything else — including asking the user for input, via `Resolve` — is era-portable by construction. Write the modern thing once.
+* Everything else (including asking the user for input, via `Resolve`) is era-portable by construction. Write the modern thing once.
diff --git a/docs/servers/index.md b/docs/servers/index.md
index 4989fe2d9..72eda00a4 100644
--- a/docs/servers/index.md
+++ b/docs/servers/index.md
@@ -15,16 +15,16 @@ decides to use them:
Around the three primitives, the rest of what a server declares:
-* **[Completions](completions.md)** — server-side autocomplete for prompt
+* **[Completions](completions.md)** is server-side autocomplete for prompt
and resource-template arguments.
-* **[Images, audio & icons](media.md)** — everything a tool can
+* **[Images, audio & icons](media.md)** covers everything a tool can
return besides text, and the icons a client shows next to your server.
-* **[Handling errors](handling-errors.md)** — the difference between an
+* **[Handling errors](handling-errors.md)** explains the difference between an
error the model can recover from and one it must never see.
Every page here stands on its own; jump straight to the one you need. If you haven't
built a server yet, start with **[First steps](../get-started/first-steps.md)** instead.
-What happens *inside* the functions you register — the `Context`, dependency injection,
-asking the user for more input mid-call — is the next section,
+What happens *inside* the functions you register (the `Context`, dependency injection,
+asking the user for more input mid-call) is the next section,
**[Inside your handler](../handlers/index.md)**.
diff --git a/docs/servers/media.md b/docs/servers/media.md
index 5f55ee30f..2519df553 100644
--- a/docs/servers/media.md
+++ b/docs/servers/media.md
@@ -105,4 +105,4 @@ A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `
* An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`.
* `icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects.
-That is everything a tool can put *into* a result. What happens when a tool *fails* — and who should find out — is **[Handling errors](handling-errors.md)**.
+That is everything a tool can put *into* a result. What happens when a tool *fails* (and who should find out) is **[Handling errors](handling-errors.md)**.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index f5ec9f328..621b32c6c 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -256,11 +256,11 @@ The fix is to reconnect: leave the `async with Client(...)` block and enter a ne
If it happens *without* a restart, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`).
-For the server operator, the matching log line is `Rejected request with unknown or expired session ID: ` — logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting.
+For the server operator, the matching log line is `Rejected request with unknown or expired session ID: `. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting.
## `MCPError: Method not found`
-One side sent a JSON-RPC request the other has no handler for, and `e.error.data` names the method. The usual cause is an **era mismatch**: a method that exists in one protocol revision and not in the other, sent to a peer on the wrong one — a `2025`-era `resources/subscribe` arriving at a `2026-07-28` connection, a `2026`-only `subscriptions/listen` sent by a client pinned to `mode="legacy"`. **[Protocol versions](protocol-versions.md)** is the map of which side speaks what, and the other honest cause — an optional capability you never registered a handler for — is on **[Completions](servers/completions.md)**.
+One side sent a JSON-RPC request the other has no handler for, and `e.error.data` names the method. The usual cause is an **era mismatch**: a method that exists in one protocol revision and not in the other, sent to a peer on the wrong one, such as a `2025`-era `resources/subscribe` arriving at a `2026-07-28` connection, or a `2026`-only `subscriptions/listen` sent by a client pinned to `mode="legacy"`. **[Protocol versions](protocol-versions.md)** is the map of which side speaks what, and the other honest cause (an optional capability you never registered a handler for) is on **[Completions](servers/completions.md)**.
One thing does **not** produce this error, despite being a request the modern protocol removed: a tool calling `ctx.elicit()` on a `2026-07-28` connection. The server refuses to *send* that request at all, so what you get instead is `Cannot send 'elicitation/create': ...`, further down this page.
@@ -301,13 +301,13 @@ async def main() -> None:
The same gap as `Client did not declare the form elicitation capability ...`, spelled by the paths that don't check up front: the server needed an elicitation answered, and the connected client registered no `elicitation_callback`.
-You see this one from `ctx.elicit()` on a legacy connection, and — on any connection — from a returned multi-round-trip question (**[Multi-round-trip requests](handlers/multi-round-trip.md)**) that reaches a client with no callback to answer it. The fix is identical: pass `elicitation_callback=` to `Client(...)`. There is no version of "the user wasn't asked" that your tool receives as a `decline`; a client that cannot be asked is a failed call, so design your tools for it.
+You see this one from `ctx.elicit()` on a legacy connection, and on any connection at all from a returned multi-round-trip question (**[Multi-round-trip requests](handlers/multi-round-trip.md)**) that reaches a client with no callback to answer it. The fix is identical: pass `elicitation_callback=` to `Client(...)`. There is no version of "the user wasn't asked" that your tool receives as a `decline`; a client that cannot be asked is a failed call, so design your tools for it.
## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.`
Your handler tried to reach the client mid-request, on a connection where nothing can carry a request from the server. There are exactly two ways to be on one.
-**A `2026-07-28` connection — any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this — on the very first in-memory test, since `Client(server)` negotiates `2026-07-28` without being asked — and passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer:
+**A `2026-07-28` connection: any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this (on the very first in-memory test, since `Client(server)` negotiates `2026-07-28` without being asked), and passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer:
```python title="server.py" hl_lines="16"
--8<-- "docs_src/troubleshooting/tutorial006.py"
@@ -370,12 +370,12 @@ WARNING mcp.server.request_state: requestState rejected on tools/call: malformed
The reasons you will actually see:
* **`unknown key`** is the one that matters. The default sealing key is generated at process start, so a retry that lands on a **different worker**, a different instance behind a load balancer, or the same server **after a restart** was sealed under a key this process never had. That is not an attacker; it is the default meeting more than one process.
-* **`audience`**: the token was sealed by an instance with a *different server name*. The name is the seal's default audience claim, so a fleet must share the name — or set an explicit `RequestStateSecurity(audience=...)` — as well as the keys.
+* **`audience`**: the token was sealed by an instance with a *different server name*. The name is the seal's default audience claim, so a fleet must share the name (or set an explicit `RequestStateSecurity(audience=...)`) as well as the keys.
* **`expired`**: the round took longer than the seal's `ttl`, which is 600 seconds and per round, not per call.
* **`malformed`** / **`codec error`**: the token was altered in transit, or was never a sealed token at all.
* **`request binding`**: the token came back with a different tool, different arguments, or a different method.
-The multi-process fix is one argument — the *same* `keys` on every instance — plus one thing that is not an argument at all: the same server *name* (or an explicit shared `audience=`).
+The multi-process fix is one argument (the *same* `keys` on every instance) plus one thing that is not an argument at all: the same server *name* (or an explicit shared `audience=`).
```python
mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key]))
From 54627279a4e1e0700908bffa158aa9196abea98a Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 19:21:26 +0000
Subject: [PATCH 14/15] docs: address review feedback (nine small fixes across
ten files)
A review pass left 13 comments; nine needed action and each fix is a
word or a line.
- testing.md over-claimed that every example runs "through exactly this
client". Every example file IS exercised by the suite, but two of the
42 docs test modules never construct a Client (the OAuth examples
cannot be driven that way), so it now says "almost all of them".
- oauth-clients.md's "Every other example in these docs you can check
with an in-memory Client(server)" had the same shape (the identity
assertion example cannot be either); now "Most examples".
- handling-errors.md's new hand-off promised Troubleshooting covers
"every error the SDK produces". The Troubleshooting page scopes
itself the honest way round, so the hand-off now matches it.
- dependencies.md's closing hand-off read "State your server builds
once at startup ... is the Lifespan", which garden-paths as an
imperative; it gained its determiner and its head noun.
- asgi.md's tip said "the next section is what host= actually
controls", but this PR moved that explanation to Deploy & scale; the
tip now points there.
- media.md gains a relative pronoun: "the TextContent that a plain str
result becomes".
- README's two get-started links still pointed at the old /v2/tutorial/
URL, which this PR turns into a redirect stub; both now point at
/v2/get-started/.
- docs/hooks/llms_txt.py's docstring example path named the removed
tutorial/ directory; it now names the moved page's real path.
- RELEASE.md's list of version-pin locations gains the new
docs/get-started/real-host.md (which pins the version seven times),
and examples/README.md no longer claims the simple-auth pair is
linked from docs/advanced/ (this PR moved every page that links it).
Two other comments were already addressed by earlier commits on this
branch (the chapter-to-page sweep and the branch-coverage fix), and two
were declined with evidence. The claimed "sentence fragment" in
media.md is a complete sentence (a contact relative clause) read one
wrapped line at a time. And gating the deploy example's refund on
request_state instead of input_responses would not add a
human-confirmation guarantee: input_responses is a wire parameter only
the MCP client can send, and that same client authors the elicitation
answer on the honest second round; the SDK's own Client documents
seeding input_responses on the first call.
---
README.md | 4 ++--
RELEASE.md | 2 +-
docs/client/oauth-clients.md | 2 +-
docs/get-started/testing.md | 4 ++--
docs/handlers/dependencies.md | 2 +-
docs/hooks/llms_txt.py | 2 +-
docs/run/asgi.md | 4 ++--
docs/servers/handling-errors.md | 2 +-
docs/servers/media.md | 2 +-
examples/README.md | 2 +-
10 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 2a50bc5ec..1324ac57e 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@
**The documentation lives at .**
-It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/v2/tutorial/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/).
+It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/v2/get-started/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/).
## What is MCP?
@@ -82,7 +82,7 @@ Call `add` with `a=1`, `b=2` and you get `3` back.
Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring.
-[Get started](https://py.sdk.modelcontextprotocol.io/v2/tutorial/) takes it from here.
+[Get started](https://py.sdk.modelcontextprotocol.io/v2/get-started/) takes it from here.
## A client in 10 lines
diff --git a/RELEASE.md b/RELEASE.md
index 28a108f75..70eef5d69 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -35,7 +35,7 @@ the publish job — `skip-existing` makes it skip whatever already landed. The
1. Update the pre-release version examples in `README.md` and the docs
(grep the outgoing version — the pins live in the README Installation
- section, `docs/index.md`, and `docs/get-started/installation.md`) so the tagged
+ section, `docs/index.md`, `docs/get-started/installation.md`, and `docs/get-started/real-host.md`) so the tagged
commit — and therefore the README PyPI publishes — names the version
being released. When entering a new phase (alpha → beta → rc), update
the banner wording too.
diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md
index 0b5953596..bde925d4d 100644
--- a/docs/client/oauth-clients.md
+++ b/docs/client/oauth-clients.md
@@ -87,7 +87,7 @@ You wrote none of it. Three keyword arguments remain (`timeout`, `client_metadat
### Try it
-Every other example in these docs you can check with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server.
+Most examples in these docs you can check with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server.
The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this page's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by.
diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md
index 68b3c443e..82a113cfb 100644
--- a/docs/get-started/testing.md
+++ b/docs/get-started/testing.md
@@ -99,8 +99,8 @@ Leave it on in tests. It has no meaning in production code.
failure inside the server task instead of in your test.
That one line is also why these docs can promise you that their examples work: every
-example file is exercised by the SDK's own test suite through exactly this client. You're using the
-same tool the SDK uses on itself.
+example file is exercised by the SDK's own test suite, almost all of them through exactly this
+client. You're using the same tool the SDK uses on itself.
You have a working, tested server. Putting it inside a real application (Claude Desktop, an
IDE) is **[Connect to a real host](real-host.md)**; every other way to serve it is
diff --git a/docs/handlers/dependencies.md b/docs/handlers/dependencies.md
index 9ab9dc449..6260b72f5 100644
--- a/docs/handlers/dependencies.md
+++ b/docs/handlers/dependencies.md
@@ -142,4 +142,4 @@ That's the right default for a precondition: no answer, no order. When declining
* 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.
-State your server builds once at startup, and how a handler reaches it, is the **[Lifespan](lifespan.md)**.
+The state your server builds once at startup, and how a handler reaches it, is the **[Lifespan](lifespan.md)** page.
diff --git a/docs/hooks/llms_txt.py b/docs/hooks/llms_txt.py
index d8ac13eb2..c6dea3196 100644
--- a/docs/hooks/llms_txt.py
+++ b/docs/hooks/llms_txt.py
@@ -5,7 +5,7 @@
- `llms.txt`: a markdown index of the documentation, one link per page,
grouped by nav section.
- a `.md` rendition of every prose page next to its HTML (e.g.
- `tutorial/tools/index.md`), which is what the llms.txt links point at.
+ `servers/tools/index.md`), which is what the llms.txt links point at.
- `llms-full.txt`: every prose page concatenated for single-fetch consumption.
Page markdown is the source markdown with `--8<--` snippet includes resolved
diff --git a/docs/run/asgi.md b/docs/run/asgi.md
index 858c93b60..2eca9273c 100644
--- a/docs/run/asgi.md
+++ b/docs/run/asgi.md
@@ -30,8 +30,8 @@ Run the app on its own (`uvicorn server:app`) and you never think about either.
!!! tip
`streamable_http_app()` takes the same keyword arguments as `mcp.run("streamable-http", ...)`,
minus `port`: the port belongs to whatever serves the app. `host` is still accepted but binds
- nothing here; the next section is what it actually controls. **[Running your server](index.md)** covers the
- options themselves.
+ nothing here; **[Deploy & scale](deploy.md)** explains what it actually controls.
+ **[Running your server](index.md)** covers the options themselves.
`mcp.sse_app()` does the same for the superseded SSE transport.
diff --git a/docs/servers/handling-errors.md b/docs/servers/handling-errors.md
index 16edcc920..0cb0a7df3 100644
--- a/docs/servers/handling-errors.md
+++ b/docs/servers/handling-errors.md
@@ -131,4 +131,4 @@ It means a whole class of `raise` statements you don't write: don't re-validate
Errors handled. That is everything a server *exposes*. What every handler can read, and do back to the client while it runs, is the next section: **[Inside your handler](../handlers/index.md)**.
-The exact text of every error the SDK produces, what it means, and the one-move fix for each is **[Troubleshooting](../troubleshooting.md)**.
+The exact text of the SDK errors you are most likely to meet, what each means, and the one-move fix for each is **[Troubleshooting](../troubleshooting.md)**.
diff --git a/docs/servers/media.md b/docs/servers/media.md
index 2519df553..e5e8a7656 100644
--- a/docs/servers/media.md
+++ b/docs/servers/media.md
@@ -30,7 +30,7 @@ Two things to notice:
!!! info
`ImageContent` and `AudioContent` live in `mcp_types`, right next to the `TextContent`
- a plain `str` result becomes (**[Tools](tools.md)**). A tool result is a list of content blocks; `Image` and `Audio` are
+ that a plain `str` result becomes (**[Tools](tools.md)**). A tool result is a list of content blocks; `Image` and `Audio` are
the shortest way to produce the two binary kinds.
### Try it
diff --git a/examples/README.md b/examples/README.md
index 4bfa140bd..68c8ac908 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -16,7 +16,7 @@
- [`clients/`](clients/) and the remaining [`servers/`](servers/) directories
(`simple-*`, `sse-polling-demo`, `structured-output-lowlevel`) — standalone
v1-era projects retained pending consolidation into `stories/` (the
- `simple-auth` pair is still linked from `docs/advanced/`).
+ `simple-auth` pair is still linked from `docs/run/authorization.md` and `docs/client/oauth-clients.md`).
For real-world servers see the
[servers repository](https://github.com/modelcontextprotocol/servers).
From 59e7a5959ea74b27d9b7cf0946dff0020f6aeb48 Mon Sep 17 00:00:00 2001
From: Max Isbey <224885523+maxisbey@users.noreply.github.com>
Date: Wed, 1 Jul 2026 19:42:14 +0000
Subject: [PATCH 15/15] docs: drop the URL redirects for the moved pages
Review call by the maintainer. The docs site's URLs are days old (the
/v2/ book shipped just before 2.0.0b1) and v2 is a beta, so there is
nothing meaningful to keep alive at the 28 moved pages' old URLs.
Remove the mkdocs-redirects plugin, its dependency, and the 28-entry
redirect map. Anyone holding a days-old deep link lands on the themed
404 page, which carries the full navigation.
The mkdocs-material floor bump that arrived in the same dependency
commit stays: navigation.path is enabled in mkdocs.yml and genuinely
requires 9.7.0.
---
mkdocs.yml | 30 ------------------------------
pyproject.toml | 1 -
uv.lock | 14 --------------
3 files changed, 45 deletions(-)
diff --git a/mkdocs.yml b/mkdocs.yml
index 1f2192fb6..5da05cc42 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -171,36 +171,6 @@ plugins:
- docs/hooks/gen_ref_pages.py
- literate-nav:
nav_file: SUMMARY.md
- - redirects:
- redirect_maps:
- advanced/authorization.md: run/authorization.md
- advanced/caching.md: client/caching.md
- advanced/deprecated.md: deprecated.md
- advanced/identity-assertion.md: client/identity-assertion.md
- advanced/multi-round-trip.md: handlers/multi-round-trip.md
- advanced/oauth-clients.md: client/oauth-clients.md
- advanced/opentelemetry.md: run/opentelemetry.md
- advanced/session-groups.md: client/session-groups.md
- advanced/subscriptions.md: handlers/subscriptions.md
- advanced/uri-templates.md: servers/uri-templates.md
- client/protocol-versions.md: protocol-versions.md
- installation.md: get-started/installation.md
- tutorial/completions.md: servers/completions.md
- tutorial/context.md: handlers/context.md
- tutorial/dependencies.md: handlers/dependencies.md
- tutorial/elicitation.md: handlers/elicitation.md
- tutorial/first-steps.md: get-started/first-steps.md
- tutorial/handling-errors.md: servers/handling-errors.md
- tutorial/index.md: get-started/index.md
- tutorial/lifespan.md: handlers/lifespan.md
- tutorial/logging.md: handlers/logging.md
- tutorial/media.md: servers/media.md
- tutorial/progress.md: handlers/progress.md
- tutorial/prompts.md: servers/prompts.md
- tutorial/resources.md: servers/resources.md
- tutorial/structured-output.md: servers/structured-output.md
- tutorial/testing.md: get-started/testing.md
- tutorial/tools.md: servers/tools.md
- mkdocstrings:
handlers:
python:
diff --git a/pyproject.toml b/pyproject.toml
index 7603367ed..c46f81d8d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -81,7 +81,6 @@ docs = [
"mkdocs-glightbox>=0.4.0",
"mkdocs-literate-nav>=0.6.1",
"mkdocs-material[imaging]>=9.7.0",
- "mkdocs-redirects>=1.2.2",
"mkdocstrings-python>=2.0.1",
]
codegen = ["datamodel-code-generator==0.57.0"]
diff --git a/uv.lock b/uv.lock
index ef9efb005..2646eda9d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -964,7 +964,6 @@ docs = [
{ name = "mkdocs-glightbox" },
{ name = "mkdocs-literate-nav" },
{ name = "mkdocs-material", extra = ["imaging"] },
- { name = "mkdocs-redirects" },
{ name = "mkdocstrings-python" },
]
@@ -1022,7 +1021,6 @@ docs = [
{ name = "mkdocs-glightbox", specifier = ">=0.4.0" },
{ name = "mkdocs-literate-nav", specifier = ">=0.6.1" },
{ name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.7.0" },
- { name = "mkdocs-redirects", specifier = ">=1.2.2" },
{ name = "mkdocstrings-python", specifier = ">=2.0.1" },
]
@@ -1630,18 +1628,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
]
-[[package]]
-name = "mkdocs-redirects"
-version = "1.2.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "mkdocs" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f1/a8/6d44a6cf07e969c7420cb36ab287b0669da636a2044de38a7d2208d5a758/mkdocs_redirects-1.2.2.tar.gz", hash = "sha256:3094981b42ffab29313c2c1b8ac3969861109f58b2dd58c45fc81cd44bfa0095", size = 7162, upload-time = "2024-11-07T14:57:21.109Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c4/ec/38443b1f2a3821bbcb24e46cd8ba979154417794d54baf949fefde1c2146/mkdocs_redirects-1.2.2-py3-none-any.whl", hash = "sha256:7dbfa5647b79a3589da4401403d69494bd1f4ad03b9c15136720367e1f340ed5", size = 6142, upload-time = "2024-11-07T14:57:19.143Z" },
-]
-
[[package]]
name = "mkdocstrings"
version = "0.30.0"