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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
# MCP Apps
An **MCP App** is a tool with a face: alongside its data, the tool points at an HTML
document the host renders as an interactive surface.
Two parts, always two parts:
1. **A tool** that does the work and returns data, like any other tool.
2. **A `ui://` resource** containing the HTML the host shows for it.
The tool carries a `_meta.ui.resourceUri` reference to the resource. The host fetches
it with `resources/read`, renders it in a **sandboxed iframe**, and pushes the tool's
result into that iframe via `postMessage`. Your server never sends or receives any
`ui/*` messages: that traffic is between the host and the iframe. You serve a tool
and an HTML document; the host does the theater.
The SDK ships this as the built-in `Apps` extension (`io.modelcontextprotocol/ui`).
If [Extensions](extensions.md) are new to you, skim that page first. One minute,
then come back.
## A clock with a face
```python title="server.py" hl_lines="18 21 29 31"
--8<-- "docs_src/apps/tutorial001.py"
```
Four moves:
* `Apps()`: one instance holds your UI-bound tools and their resources.
* `@apps.tool(resource_uri="ui://clock/app.html")`: a regular tool, plus the
`_meta.ui.resourceUri` stamp. Everything `@mcp.tool()` accepts (name, title,
description, ...) passes through.
* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: the matching
resource, served as `text/html;profile=mcp-app`. That exact MIME type is what
tells a host "this is an app, render it".
* `MCPServer("clock", extensions=[apps])`: opt in. The server now advertises
`io.modelcontextprotocol/ui` under `capabilities.extensions`.
The HTML itself listens for the host's `postMessage` and shows the result. For real
apps, use the official [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps)
browser SDK inside your HTML. It gives you `ontoolresult`, `callServerTool`,
`getHostContext`, and `onhostcontextchanged` instead of raw message events.
## Graceful degradation
Not every client renders apps. The spec is blunt about what that means for you:
> Tools **MUST** return a meaningful `content` array even when UI is available.
The model reads `content`; the iframe is for humans. A UI-capable host still feeds
the text result to the model, and a text-only client gets *only* that. So the
canonical pattern is one tool, two answers. Look at `get_time` again:
```python title="server.py" hl_lines="22-26"
--8<-- "docs_src/apps/tutorial001.py"
```
`client_supports_apps(ctx)` is `True` only when the client declared the
`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app`
in its `mimeTypes` settings. The field is required, so a client that omits it
does not count. That is exactly what `main()` in the same file declares: the
client half of the negotiation, and the rich answer comes back.
!!! warning
Never return a placeholder like `"[Rendered UI]"` as the only content. If the
fallback text is useless, the tool is useless to every text-only client and to
the model itself. Write the sentence.
## Locking the iframe down
The resource side carries the security metadata: what the iframe may load, which
browser permissions it wants, how it would like to be framed:
```python title="server.py" hl_lines="9 19-22"
--8<-- "docs_src/apps/tutorial002.py"
```
`csp` and `permissions` are **requests to the host**, not server behaviour. The host
builds the iframe's Content-Security-Policy and Permissions-Policy from them, and it
may refuse. Feature-detect in your JS rather than assuming a grant.
`ResourceCsp`, field by field (Python name, wire key, what the host does with it):
| Python | Wire (`_meta.ui.csp`) | Controls |
|---|---|---|
| `connect_domains` | `connectDomains` | `connect-src`: where `fetch`/XHR may go |
| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: static assets |
| `frame_domains` | `frameDomains` | `frame-src`: nested iframes |
| `base_uri_domains` | `baseUriDomains` | `base-uri`: what `<base>` may point at |
`ResourcePermissions`: each field requests a browser permission for the iframe.
| Python | Wire (`_meta.ui.permissions`) |
|---|---|
| `camera` | `camera` |
| `microphone` | `microphone` |
| `geolocation` | `geolocation` |
| `clipboard_write` | `clipboardWrite` |
!!! note
CSP and permissions live on the **resource**, never on the tool. The spec's tool
metadata has no slot for them, and hosts ignore them there. The SDK makes the
mistake unrepresentable: `@apps.tool()` simply has no `csp` parameter.
### Visibility
`visibility=["app"]` on a tool says "this exists for the iframe, not the model":
* `"model"`: the model may call it.
* `"app"`: the iframe may call it (via `callServerTool`).
* Omitted: both, which is the default.
Filtering is the **host's** job. Your server lists app-only tools in `tools/list`
like any other; the host hides them from the model. Don't filter server-side.
## The rules the SDK enforces
All of these fail at startup, not in production:
* A `resource_uri` or resource URI that isn't `ui://...` is a `ValueError` at
decoration/registration time.
* A tool bound to a URI with **no matching registered resource** is a `ValueError`
when `MCPServer(extensions=[apps])` consumes the extension. A tool advertising
HTML that 404s on `resources/read` is a misconfiguration, so it refuses to
construct.
* `meta={"ui": ...}` on `@apps.tool()` is a `ValueError`. The decorator owns
`_meta["ui"]`; say it with `resource_uri=` and `visibility=`. Other `meta=` keys
merge fine alongside.
Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd
rather you find out before a host does.
## Beyond inline HTML
`add_html_resource` covers the common case: a string of HTML. For anything else,
HTML on disk or generated content, build the resource yourself and hand it over:
```python title="server.py" hl_lines="12 18"
--8<-- "docs_src/apps/tutorial003.py"
```
`add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource
doesn't set one explicitly, and rejects an explicit mismatch: a `ui://` resource
under any other MIME type is one no host will render.
!!! tip
Targeting a pre-GA host that still reads the deprecated flat
`_meta["ui/resourceUri"]` key? Merge it yourself:
`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`.
The nested `ui` object is the spec shape; the flat key is on its way out.
## See it run
The `apps` story in `examples/stories/` is this page as a runnable pair: a server
with a UI-bound clock tool and a client that negotiates Apps, reads the tool's
`_meta.ui.resourceUri`, fetches the HTML, and calls the tool.
```bash
uv run python -m stories.apps.client
```
+249
View File
@@ -0,0 +1,249 @@
# Extensions
An **extension** is an opt-in bundle of MCP behaviour behind one identifier.
On a server it can contribute tools, resources, and new request methods, and it can wrap
`tools/call`. On a client it can claim extra `tools/call` result shapes and observe vendor
notifications. Each side advertises under its own `capabilities.extensions`, and nothing
changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and
it has one golden rule: **extensions are off by default**.
## Using an extension
Pass instances at construction:
```python title="server.py"
--8<-- "docs_src/extensions/tutorial001.py"
```
Done. The server now advertises `io.modelcontextprotocol/ui` under
`capabilities.extensions` and serves everything the extension contributes.
`Apps` is the built-in reference extension, and it gets its own page: **[MCP Apps](apps.md)**.
!!! note
Extensions are fixed at construction. There is no `add_extension` to call later:
a server's capability map should not change while clients are connected to it.
The capability map rides `server/discover`, which is a **2026-07-28** path. A legacy
`initialize` handshake has nowhere to put it, so a legacy client simply doesn't see
the extension. Design for that: an extension *augments* a server, it must not be the
only way the server is usable.
## Writing your own
Subclass `Extension` and override only what you need. Every method has a default.
### The identifier
```python
--8<-- "docs_src/extensions/tutorial002.py"
```
The identifier is a `vendor-prefix/name` string following the spec's `_meta` key
grammar: dot-separated labels (each starts with a letter, ends with a letter or
digit), a slash, then the name. It is validated **when the class is defined**, so a
typo doesn't wait for a server to boot:
```text
TypeError: Stamps.identifier must be a `vendor-prefix/name` string
(reverse-DNS prefix required), got 'stamps'
```
Use a domain you control as the prefix. `io.modelcontextprotocol/*` is for extensions
specified by the MCP project itself.
### Contributing tools
The smallest useful extension is one tool and a settings map:
```python title="server.py" hl_lines="17 19-20 22-23 26"
--8<-- "docs_src/extensions/tutorial003.py"
```
* `tools()` returns `ToolBinding`s. The server registers each one exactly as if you
had called `mcp.add_tool(...)` yourself: same schema generation, same `Context`
injection, same everything.
* `settings()` is the value advertised at `capabilities.extensions["com.example/stamps"]`.
Return `{}` (the default) to advertise the extension with no settings.
* The extension never receives the server. It declares contributions as data;
`MCPServer` consumes them. There is no `self.server` to mutate.
And `main()` is the proof, an in-memory client straight against `mcp`:
```python title="server.py" hl_lines="29-34"
--8<-- "docs_src/extensions/tutorial003.py"
```
### Serving your own methods
An extension can register **new request methods**: its own verbs, served next to the
spec's:
```python title="server.py" hl_lines="16-22 31 40-48"
--8<-- "docs_src/extensions/tutorial004.py"
```
* `SearchParams` subclasses `RequestParams`, so the 2026 `_meta` envelope parses
uniformly and your handler gets validated params, never a raw dict. Bound what
the client controls: `Field(ge=1, le=100)` rejects an absurd `limit` before
your code allocates anything for it.
* `require_client_extension(ctx, EXTENSION_ID)` is the gate: a client that did not
declare the extension gets the `-32021` (missing required client capability) error,
with the machine-readable `requiredCapabilities` payload the spec asks for.
* `protocol_versions=frozenset({"2026-07-28"})` pins the method to one wire version.
At any other version the client gets `METHOD_NOT_FOUND`, exactly as if the method
didn't exist there. For that client, it doesn't.
Methods are **strictly additive**. The SDK enforces this at construction, not at
runtime:
* A `MethodBinding` for a spec-defined method (`tools/list`, `completion/complete`, ...)
raises `ValueError` when the binding is constructed. Core verbs belong to the server.
* Two extensions binding the same method raise when the second one registers.
Last-write-wins is how plugins corrupt each other; we don't do that.
* An empty `protocol_versions` set raises too: a method that can never be served
is a bug, not a configuration.
### The client side
The same file's `main()` is the whole client story, both halves of it:
```python title="server.py" hl_lines="54-58"
--8<-- "docs_src/extensions/tutorial004.py"
```
* `Client(..., extensions=[advertise(EXTENSION_ID)])` declares the extension. The
declarations become `ClientCapabilities.extensions`: on a 2026-07-28 connection
the map travels in the per-request `_meta` envelope, so the server sees it on
**every** request; on a legacy connection it rides the `initialize` handshake.
Server code doesn't care which: `require_client_extension(ctx, ...)` and
`ctx.session.check_client_capability(...)` read the right source on both paths.
* Vendor methods drop one layer to `client.session.send_request(...)`; `Client`
only grows first-class methods for spec verbs. `send_request` accepts any
`Request` subclass, so the vendor request passes as-is.
### Intercepting `tools/call`
The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit,
or veto a tool call:
```python title="server.py" hl_lines="18-25"
--8<-- "docs_src/extensions/tutorial005.py"
```
* `params` is the validated `CallToolRequestParams`: you get `params.name` and
`params.arguments` without touching raw JSON.
* `call_next(ctx)` runs the rest of the chain. Return its result unchanged (observe),
return something else (replace), or raise an `MCPError` (refuse).
* With several extensions, interceptors nest in registration order: the first
extension in `extensions=[...]` is outermost.
* The default implementation is a pass-through, and a server whose extensions never
override this hook installs **no** middleware at all. You don't pay for what
you don't use.
The hook wraps `tools/call` and nothing else. For every-message concerns, use
[Middleware](middleware.md). That is what it is for.
## Using a client extension
A **client extension** is the same contract from the consuming side: a bundle of
client-side behaviour behind one identifier. Pass instances to
`Client(extensions=[...])` and call tools normally:
```python title="client.py" hl_lines="67-69"
--8<-- "docs_src/extensions/tutorial006.py"
```
`call_tool("buy", ...)` returns a plain `CallToolResult`, like every other call. What
the extension changed: the server may now answer `buy` with a `receipt` **result
shape** instead of a final result, and `Receipts` finishes it (here by redeeming the
receipt with a follow-up call) before `call_tool` returns. Nothing about the call
site moves.
Drop the extension and none of this exists: the server's gate refuses a client
that did not declare it (error -32021), and a claimed shape from a server that
skips the gate fails validation, exactly as the spec requires for an
unrecognized `resultType`. Off by default, on both ends of the wire.
To advertise an identifier with **no** client-side behaviour (the server gates on
the capability, the client does nothing, as in the search client above), use
`advertise()`:
```python
from mcp.client import advertise
client = Client(mcp, extensions=[advertise("com.example/search")])
```
## Writing a client extension
Subclass `ClientExtension` and override only what you need. Three contribution
kinds, each with a default: `settings()`, `claims()`, and `notifications()`.
```python title="client.py" hl_lines="18-19 44-45 47-48"
--8<-- "docs_src/extensions/tutorial006.py"
```
* The identifier follows the same grammar as the server's, validated when the class
is defined.
* `claims()` returns `ResultClaim`s: a wire tag, the model that parses it, and the
resolver that finishes it. The model must pin the tag with
`result_type: Literal["receipt"]` and must not subclass the verb's core result
types; both are enforced when the claim is constructed. Vendor fields like
`receipt_token` ride the wire as-is: a substituted shape reaches the client
verbatim.
* The resolver receives the parsed model and a `ClaimContext`; `ctx.session` is the
same public handle as `client.session`, so follow-ups are ordinary session calls.
It returns the verb's normal `CallToolResult`.
* `settings()` is the value advertised at `ClientCapabilities.extensions[identifier]`,
read once at `Client` construction.
`notifications()` declares vendor server notifications to observe:
```python
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)]
```
The handler receives validated params one at a time, in dispatch order. It observes; it cannot veto
or reply.
Two quiet rules. Claims are active on 2026-07-28 connections only, and the capability
ad follows them: on a legacy connection the claims dissolve and the identifier drops
out of the ad with them, so the client never advertises an extension whose shapes it
would reject. And when you want the claimed shape yourself instead of the resolver,
call `client.session.call_tool(..., allow_claimed=True)`; without that flag, a
claimed shape reaching a session-tier caller raises `UnexpectedClaimedResult`.
### Extension verbs
An extension's own request methods need no client-side registration. A vendor request
type subclasses `mcp_types.Request` and goes through `client.session.send_request`,
as in [Serving your own methods](#serving-your-own-methods). One addition: when a
params key must ride the `Mcp-Name` header (extension specs such as tasks require
this for their verbs), the request type declares `name_param`:
```python title="client.py" hl_lines="23-26 47-48"
--8<-- "docs_src/extensions/tutorial007.py"
```
The session mirrors `params["jobId"]` into `Mcp-Name` on every send path, and a
missing value fails loudly rather than silently omitting a required header.
## What an extension cannot do
The contribution surface is **closed** on purpose. On the server: settings, tools,
resources, methods, one `tools/call` interceptor. On the client: settings, result
claims, notification bindings. An extension cannot:
* **Reach into the host.** It declares data; it holds no server or client reference.
* **Replace core behaviour.** Spec methods and core result tags are rejected at
construction (`initialize` is reserved by the runner outright); a notification
binding shadowed by core vocabulary goes quiet with a warning instead.
* **Register late.** After `MCPServer(...)` or `Client(...)` returns, the extension
set is what it is.
If you are fighting these walls, you are not writing an extension. You are writing
a fork. The walls are the feature: a user reading `extensions=[Apps(), Stamps()]`
knows *everything* those two can have touched.
+29
View File
@@ -0,0 +1,29 @@
# Advanced
Everything an ordinary server or client needs has a topical home in the sections above.
This section is the escape hatches you reach for when `MCPServer`'s convenience
layer is in the way:
* **[The low-level Server](low-level-server.md)**: the class `MCPServer` is built on.
Hand-written schemas, `on_*` handlers, nothing checked for you, and custom JSON-RPC
methods of your own.
* **[Pagination](pagination.md)** and **[Middleware](middleware.md)**: two things you
can *only* do on the low-level `Server`.
* **[Extensions](extensions.md)** and **[MCP Apps](apps.md)**: the protocol's
extension surface. Compose extension packages into a server, or write your own.
A few things you might reasonably look for here live where you'd actually use them
instead:
* **Authorization** is under **[Running your server](../run/index.md)** because you
protect a server where you deploy it.
* **OAuth**, **identity assertion**, connecting to **multiple servers**, and the
response **cache** are all under **[Clients](../client/index.md)**.
* **Multi-round-trip requests** and **Subscriptions** are under
**[Inside your handler](../handlers/index.md)** because both are things a
handler *does*.
* **URI templates** is under **[Servers](../servers/index.md)**, next to Resources.
* **[Protocol versions](../protocol-versions.md)** and
**[Deprecated features](../deprecated.md)** each have their own top-level page.
If you're not sure whether you need this section, you don't.
+199
View File
@@ -0,0 +1,199 @@
# The low-level Server
`@mcp.tool()` is a layer. Underneath it is a second server class, `Server`, that speaks raw MCP: you hand it the protocol objects and it puts them on the wire, unchanged.
`MCPServer` is built on top of it. You drop down when the convenience layer is in the way:
* You need to emit an **exact** schema (loaded from a file, generated from a database), not one derived from a Python signature.
* You need full control of the result: `_meta`, `is_error`, every key of `structured_content`.
* You need to handle a method MCP doesn't define.
For everything else, stay on `MCPServer`.
## The same tool, by hand
This is 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"
```
Three things changed, and they are the whole low-level API:
* **Handlers are constructor parameters.** `on_list_tools=` and `on_call_tool=` go into `Server(...)`. There are no decorators down here, and every handler has the same shape: `async (ctx, params) -> result`.
* **You write the input schema.** `Tool.input_schema` is a plain JSON Schema `dict`. Nobody derives it from type hints, because there are no type hints to derive it from.
* **You build the result.** `CallToolResult(content=[TextContent(...)])`, by hand. Nothing is wrapped, converted, or inferred from a return annotation.
`params` is the parsed request: `CallToolRequestParams` gives you `.name` and `.arguments`. `ctx` is a `ServerRequestContext`: `ctx.session` for talking back to the client, `ctx.lifespan_context`, `ctx.request_id`, and `ctx.meta`, the request's inbound `_meta`.
!!! info
If you've used FastAPI, you already know this relationship. `MCPServer` is the decorators-and-type-hints layer; `Server` is the Starlette underneath. They are not rivals: `MCPServer` constructs a `Server` and registers handlers exactly like these on it.
### Try it
There is no Inspector for this one: `mcp dev` and `mcp run` only accept an `MCPServer`. The in-memory `Client` doesn't care; it takes a low-level `Server` exactly like it takes an `MCPServer`:
```python title="main.py"
import asyncio
from mcp import Client
from server import server
async def main() -> None:
async with Client(server) as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
print(result.content)
asyncio.run(main())
```
```text
[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)]
```
The same text the `@mcp.tool()` version produced. Two honest differences:
* `result.structured_content` is `None`. The high-level server 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
`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`.
!!! check
Call `search_books` without `limit` and your `args["limit"]` raises `KeyError`. The client sees:
```text
MCPError: Internal server error
```
A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **[Testing](../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](../servers/handling-errors.md)**.
## Two tools, one handler
`on_call_tool` is the single entry point for every tool on the server. You route on `params.name`:
```python title="server.py" hl_lines="39-44"
--8<-- "docs_src/lowlevel/tutorial002.py"
```
* `list_tools` advertises both. `call_tool` dispatches on the name.
* The `else` branch matters: `Server` will happily forward a `tools/call` for a name you never listed straight into your handler. Raising there turns the call into the same `-32603` as above.
## Structured output, by hand
Declare `output_schema` on the `Tool` and put `structured_content` on the result. Both are yours:
```python title="server.py" hl_lines="20-24 37"
--8<-- "docs_src/lowlevel/tutorial003.py"
```
Call it and the result carries both representations:
```json
{
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
"structuredContent": {"matches": 3, "query": "dune"},
"isError": false,
"resultType": "complete"
}
```
The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**.
## `_meta`: for the application, not the model
`content` is the part of the answer the model reads. `structured_content` is the same answer as typed data. `_meta` is the third channel: data that rides along with the result for the **client application**, without being part of the answer at all.
Use it for record IDs, trace IDs, anything your UI needs and your prompt doesn't:
```python title="server.py" hl_lines="38"
--8<-- "docs_src/lowlevel/tutorial004.py"
```
* You construct it as `_meta=`, the wire name. The client reads it back as `result.meta`.
* Namespace your keys (`bookshop/record_ids`). The `io.modelcontextprotocol/*` keys are reserved by the protocol.
!!! warning
`_meta` is a convention between you and the client application, not a guarantee about what reaches
the model. The host decides what it renders. Never put a secret in any part of a tool result.
## Capabilities follow your handlers
A `Server` advertises exactly the method families you gave it handlers for. The `Bookshop` above passes `on_list_tools` and `on_call_tool` and nothing else, so a client connecting to it sees:
```json
{"tools": {"listChanged": false}}
```
No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_prompts` and `prompts` appears; pass `on_completion` and `completions` appears.
`MCPServer` always advertises tools, resources and prompts, whether you registered any or not, because its managers always exist. Down here the declaration *is* the constructor call.
## The lifespan generic
`Server` is generic in the type its lifespan yields. Annotate it once and the object is typed everywhere it surfaces:
```python title="server.py" hl_lines="25-27 45-46 51"
--8<-- "docs_src/lowlevel/tutorial005.py"
```
* The lifespan is a `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` on an `async` generator gives you exactly that.
* Whatever it `yield`s becomes `ctx.lifespan_context`, and because the handlers are annotated `ServerRequestContext[Catalog]`, `.search(...)` autocompletes and type-checks.
* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **[Lifespan](../handlers/lifespan.md)**.
Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`.
## A method of your own
The constructor covers the methods MCP defines. `add_request_handler` covers everything else:
```python title="server.py" hl_lines="35-36 39-40 43-44 48"
--8<-- "docs_src/lowlevel/tutorial006.py"
```
* The first argument is the method string. Notifications have a twin, `add_notification_handler`.
* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's.
* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result.
One honest caveat: the high-level `Client` only has verbs for the methods MCP defines, so there is no `client.reindex()`. A vendor method is for a peer that already knows it exists: a client you also ship, or another service of yours speaking JSON-RPC.
One method you cannot claim:
```text
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
```
The handshake belongs to the runner. `server/discover`, `ping`, and every other built-in are yours to replace.
!!! tip
`Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **[Middleware](middleware.md)**.
## The other handlers
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.
* `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
* The low-level `Server` takes its handlers as `on_*` **constructor parameters**; every handler is `async (ctx, params) -> result`.
* You write the `input_schema` dict and you build the `CallToolResult`. Nothing is derived, wrapped, or validated for you.
* An exception in a handler is a `-32603` protocol error. A tool error the model can read is a `CallToolResult` with `is_error=True` that **you** return.
* `_meta` on the result is addressed to the client application, not the model.
* `Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`.
* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved.
* The capabilities a `Server` advertises are derived from which handlers you registered.
`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**.
+108
View File
@@ -0,0 +1,108 @@
# Middleware
A **middleware** is one async function that wraps every message your server receives.
You write it as `async (ctx, call_next)` and append it to `server.middleware`. That is the whole API.
!!! warning
`Server.middleware` is marked **provisional** in the source. The signature and semantics are
expected to change before v2 is final. Use it to *observe*: timing, logging, tracing.
Do not make it the foundation your server stands on.
This is a **low-level `Server`** feature. `MCPServer` does not expose a middleware list.
If `Server(name, on_call_tool=...)` is new to you, read **[The low-level Server](low-level-server.md)** first.
## A timing middleware
One server, one tool, one middleware that logs how long each message took:
```python title="server.py" hl_lines="40-46 50"
--8<-- "docs_src/middleware/tutorial001.py"
```
* `ctx` is the same `ServerRequestContext` your handlers receive. `ctx.method` is the raw
method string; `ctx.params` are the raw params, **before** any validation.
* `call_next(ctx)` runs the rest of the chain: validation, the handler lookup, your handler.
Return what it returned and the response is untouched.
* The `try`/`finally` is deliberate: a handler that raises is still timed, because the failure
reaches your middleware as the exception out of `call_next`.
* `server.middleware.append(...)` registers it. The list runs outermost-first, so
`middleware[0]` is the one closest to the wire.
### Try it
Connect a client, list the tools, call one. Your log has **three** lines:
```text
server/discover took 18.3 ms
tools/list took 0.1 ms
tools/call took 0.1 ms
```
You made two calls and got three lines. The first is `server/discover`: the request the
client sent to set the connection up, before you asked for anything.
That is the point. Middleware wraps **every** inbound message:
* The connection setup: `server/discover`, or `initialize` and `notifications/initialized`
on a legacy session.
* Every request and every notification. For a notification, `ctx.request_id is None`,
`call_next(ctx)` returns `None`, and whatever you return is discarded.
* Even a method the server has no handler for: `call_next` raises the
`MCPError(-32601, "Method not found")` *through* your middleware on its way to the client.
## What you can do inside one
In increasing order of how much you should hesitate:
* **Observe.** Time it, count it, log it. The example above.
* **Refuse.** Raise an `MCPError` *instead of* calling `call_next(ctx)` and that one message is
answered with a JSON-RPC error. The connection stays up; the next message goes through.
* **Rewrite.** `ctx` is a dataclass: `await call_next(dataclasses.replace(ctx, params=...))`
hands the rest of the chain different params than the client sent. Never do this to
`initialize`: the result the client gets back is built from your rewritten params, but the
server commits its connection state from the original wire params. The two sides can finish
the handshake disagreeing about what they negotiated.
!!! check
`initialize` is one of the things middleware wraps, and it is the *only* hook you get
for it. Try to take it over with `add_request_handler` and the SDK refuses:
```text
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
```
!!! warning
`initialize` is handled inline: the server reads no further inbound messages until your
middleware chain returns. Awaiting a server-to-client request (`ctx.session.send_request(...)`,
an elicitation) while handling `initialize` therefore **deadlocks the connection**: the
response you are waiting for can never be read. Fire-and-forget notifications are fine.
## The one middleware that ships on by default
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](../run/opentelemetry.md)**.
!!! info
If you have written ASGI middleware, you already know this shape. Starlette's
`(scope, receive, send)` became `(ctx, call_next)`, and it runs *after* the transport, on
the decoded message instead of the raw HTTP request. The two compose: Starlette middleware
on `streamable_http_app()` sees HTTP; this sees MCP.
## Recap
* A middleware is `async (ctx, call_next) -> result`, appended to `server.middleware` on the
low-level `Server`.
* It wraps **every** inbound message (`server/discover`, `initialize`, requests, notifications,
unknown methods) and runs outermost-first.
* `ctx.request_id is None` is how you tell a notification from a request.
* Raise instead of calling `call_next` to refuse one message; the connection survives.
* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See
**[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](../run/authorization.md)** is what decides whether the request
gets to run at all.
+80
View File
@@ -0,0 +1,80 @@
# Pagination
Most servers never need this.
`MCPServer` answers every `list_*` request with everything it has, in one page, `next_cursor=None`. For a few dozen tools, resources or prompts that is the right answer and there is nothing to configure.
Pagination is for the server whose resource list is really a database: thousands of rows it refuses to serialize in one response. The protocol's answer is a **cursor**: the server returns a page plus an opaque token, and the client sends that token back to get the next page.
`@mcp.resource()` has no hook for any of that. To page, you write the list handler yourself, on the **[low-level Server](low-level-server.md)**.
## A server that pages
```python title="server.py" hl_lines="13 16-17"
--8<-- "docs_src/pagination/tutorial001.py"
```
* On a low-level `Server`, handlers are constructor arguments, not decorators. `on_list_resources` answers every `resources/list` request; that's the whole hookup.
* Every paged handler is typed `params: PaginatedRequestParams | None`, and the example accepts both. Over a connection, though, the SDK never hands you `None` (a request with no `params` member reaches the handler as the model with its defaults), so the signal that matters is `params.cursor is None`: **start from the top**.
* You decide what a cursor *is*. Here it's an offset rendered as a string. A timestamp, a primary key, a base64 blob: anything you can mint on the way out and recognise on the way back in.
* `next_cursor=None` is how you say "that was the last page". There is no count, no total, no `has_more`. `None` is the entire signal.
!!! tip
A `PAGE_SIZE` of 10 makes the example readable. Pick yours per endpoint: a list of
one-line resources can afford a page of 500; a list of fat prompt templates cannot.
The client has no say in it, and that is by design.
### Try it
`Client(server)` connects to a low-level `Server` in memory exactly as it connects to an `MCPServer`.
Call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`.
Hand it back with `list_resources(cursor="10")` and the first resource is `book-11`, the new `next_cursor` is `"20"`.
The tenth page comes back with `next_cursor` set to `None`. Done.
## The client loop
Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) takes a `cursor=` keyword. Draining a paged list is one `while True`:
```python title="client.py" hl_lines="27-33"
--8<-- "docs_src/pagination/tutorial002.py"
```
* `cursor` starts as `None`, so the first request carries no cursor.
* Extend **before** you look at `next_cursor`: the last page has resources too.
* `next_cursor is None` is the exit. Anything else goes straight back into `cursor=`, untouched.
Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages.
This is the same loop **[The Client](../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
**Cursors are opaque.** A client must never parse, build, or guess one. The only legal source of a cursor is the previous page's `next_cursor`, verbatim.
**The server picks the page size.** There is no `limit=` in the protocol. If you need a different page size, you change the server.
**A client that ignores paging still works.** It calls `list_resources()` once, gets the first ten, and never notices the `next_cursor` it threw away. Nothing breaks; it sees less.
!!! check
Opaque means opaque. Invent a cursor (`list_resources(cursor="page-2")`) and there is
nothing the protocol can do for you. This server tries `int("page-2")`, the handler raises,
and what comes back to the client is:
```text
MCPError(-32603, 'Internal server error', None)
```
A cursor you didn't get from the server is a bug, not a feature request.
## Recap
* `MCPServer` returns everything in one page. Pagination is opt-in, and you opt in on the low-level `Server`.
* `on_list_resources` (and `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) receives `PaginatedRequestParams | None`; `params.cursor` is `None` for the first page.
* You return a page plus `next_cursor`: any string you'll recognise later, or `None` when there is nothing left.
* The client loop: pass `cursor=`, accumulate, repeat until `next_cursor is None`.
* Cursors are opaque, the server owns the page size, and a non-paging client still gets page one.
The rest of the hand-written `Server` API (`on_call_tool`, `input_schema` dicts, `_meta`) is **[The low-level Server](low-level-server.md)**.