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
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:
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)**.
|
||||
@@ -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.
|
||||
@@ -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)**.
|
||||
Reference in New Issue
Block a user