Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions docs/advanced/apps.md
Original file line number Diff line number Diff line change
@@ -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
```
159 changes: 159 additions & 0 deletions docs/advanced/extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Extensions

An **extension** is an opt-in bundle of MCP behaviour behind one identifier.

It can contribute tools, resources, and new request methods, and it can wrap `tools/call`.
The server advertises it under `capabilities.extensions`, the client opts in the same way,
and nothing changes for anyone who didn't ask for it. That is the contract (SEP-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="15-21 30 39-47"
--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="53-57"
--8<-- "docs_src/extensions/tutorial004.py"
```

* `Client(..., extensions={EXTENSION_ID: {}})` declares the extension. That map
becomes `ClientCapabilities.extensions`: on a 2026-07-28 connection it 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. The `cast` is there because
`send_request` is typed against the spec's closed request union.

### 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.

## What an extension cannot do

The contribution surface is **closed** on purpose: settings, tools, resources,
methods, one `tools/call` interceptor. An extension cannot:

* **Reach into the server.** It declares data; it holds no server reference.
* **Replace core behaviour.** Spec methods are rejected at construction, and
`initialize` is reserved by the runner outright.
* **Register late.** After `MCPServer(...)` 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.
44 changes: 44 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,50 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th

For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-<name>` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation.

### Server extensions API (SEP-2133)

`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a
reverse-DNS identifier and advertise it under `ServerCapabilities.extensions`
(the 2026-07-28 capability map). An extension subclasses `mcp.server.extension.Extension`
and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()`
(additive) and `intercept_tool_call()` (wraps `tools/call`). The `identifier` must be a
`vendor-prefix/name` string following the spec's `_meta` key grammar; a class-level
`identifier` is validated when the subclass is defined, one assigned in `__init__` when
the extension is registered. Pass instances at construction:

```python
from mcp.server.mcpserver import MCPServer
from mcp.server.apps import Apps

mcp = MCPServer("demo", extensions=[Apps()])
```

The reference extension is `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`):
it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and
`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback — `True` only
when the client's ui-extension settings list the `text/html;profile=mcp-app`
MIME type, per the Apps spec's required `mimeTypes` field. Every
`@apps.tool(resource_uri=...)` must have a matching resource registered on the
same `Apps` instance (`add_html_resource` for inline HTML, `add_resource` for a
pre-built `Resource`); a tool bound to an unregistered URI raises at
`MCPServer(...)` construction rather than 404ing on `resources/read` at runtime.

Extension methods are strictly additive: a `MethodBinding` cannot name a
spec-defined request method, and registering one whose method collides with
another handler raises at construction. A `MethodBinding` may set
`protocol_versions` to scope an extension method to specific wire versions
(`frozenset()` is rejected — use `None` to admit every version); a request at
any other version is `METHOD_NOT_FOUND`. An
extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, identifier)`
to reject a request with the `-32021` (missing required client capability) error
when the client did not declare the extension.

Clients advertise extension support with the new `Client(extensions=...)` /
`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`.
The extensions capability map is negotiated over `server/discover` (modern path);
a legacy `initialize` handshake does not carry it. Extensions are off by default
and never alter behaviour unless registered.

### `McpError` renamed to `MCPError`

The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.
Expand Down
Empty file added docs_src/apps/__init__.py
Empty file.
3 changes: 3 additions & 0 deletions docs_src/apps/report.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<!doctype html>
<title>Report</title>
<p>Quarterly numbers render here.</p>
Loading
Loading