Skip to content

Commit 4b51978

Browse files
Kludexmaxisbey
andauthored
Add a pluggable server extension API with MCP Apps (#3003)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
1 parent fe97238 commit 4b51978

39 files changed

Lines changed: 2598 additions & 24 deletions

docs/advanced/apps.md

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# MCP Apps
2+
3+
An **MCP App** is a tool with a face: alongside its data, the tool points at an HTML
4+
document the host renders as an interactive surface.
5+
6+
Two parts, always two parts:
7+
8+
1. **A tool** that does the work and returns data, like any other tool.
9+
2. **A `ui://` resource** containing the HTML the host shows for it.
10+
11+
The tool carries a `_meta.ui.resourceUri` reference to the resource. The host fetches
12+
it with `resources/read`, renders it in a **sandboxed iframe**, and pushes the tool's
13+
result into that iframe via `postMessage`. Your server never sends or receives any
14+
`ui/*` messages: that traffic is between the host and the iframe. You serve a tool
15+
and an HTML document; the host does the theater.
16+
17+
The SDK ships this as the built-in `Apps` extension (`io.modelcontextprotocol/ui`).
18+
If [Extensions](extensions.md) are new to you, skim that page first. One minute,
19+
then come back.
20+
21+
## A clock with a face
22+
23+
```python title="server.py" hl_lines="18 21 29 31"
24+
--8<-- "docs_src/apps/tutorial001.py"
25+
```
26+
27+
Four moves:
28+
29+
* `Apps()`: one instance holds your UI-bound tools and their resources.
30+
* `@apps.tool(resource_uri="ui://clock/app.html")`: a regular tool, plus the
31+
`_meta.ui.resourceUri` stamp. Everything `@mcp.tool()` accepts (name, title,
32+
description, ...) passes through.
33+
* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: the matching
34+
resource, served as `text/html;profile=mcp-app`. That exact MIME type is what
35+
tells a host "this is an app, render it".
36+
* `MCPServer("clock", extensions=[apps])`: opt in. The server now advertises
37+
`io.modelcontextprotocol/ui` under `capabilities.extensions`.
38+
39+
The HTML itself listens for the host's `postMessage` and shows the result. For real
40+
apps, use the official [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps)
41+
browser SDK inside your HTML. It gives you `ontoolresult`, `callServerTool`,
42+
`getHostContext`, and `onhostcontextchanged` instead of raw message events.
43+
44+
## Graceful degradation
45+
46+
Not every client renders apps. The spec is blunt about what that means for you:
47+
48+
> Tools **MUST** return a meaningful `content` array even when UI is available.
49+
50+
The model reads `content`; the iframe is for humans. A UI-capable host still feeds
51+
the text result to the model, and a text-only client gets *only* that. So the
52+
canonical pattern is one tool, two answers. Look at `get_time` again:
53+
54+
```python title="server.py" hl_lines="22-26"
55+
--8<-- "docs_src/apps/tutorial001.py"
56+
```
57+
58+
`client_supports_apps(ctx)` is `True` only when the client declared the
59+
`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app`
60+
in its `mimeTypes` settings. The field is required, so a client that omits it
61+
does not count. That is exactly what `main()` in the same file declares: the
62+
client half of the negotiation, and the rich answer comes back.
63+
64+
!!! warning
65+
Never return a placeholder like `"[Rendered UI]"` as the only content. If the
66+
fallback text is useless, the tool is useless to every text-only client and to
67+
the model itself. Write the sentence.
68+
69+
## Locking the iframe down
70+
71+
The resource side carries the security metadata: what the iframe may load, which
72+
browser permissions it wants, how it would like to be framed:
73+
74+
```python title="server.py" hl_lines="9 19-22"
75+
--8<-- "docs_src/apps/tutorial002.py"
76+
```
77+
78+
`csp` and `permissions` are **requests to the host**, not server behaviour. The host
79+
builds the iframe's Content-Security-Policy and Permissions-Policy from them, and it
80+
may refuse. Feature-detect in your JS rather than assuming a grant.
81+
82+
`ResourceCsp`, field by field (Python name, wire key, what the host does with it):
83+
84+
| Python | Wire (`_meta.ui.csp`) | Controls |
85+
|---|---|---|
86+
| `connect_domains` | `connectDomains` | `connect-src`: where `fetch`/XHR may go |
87+
| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: static assets |
88+
| `frame_domains` | `frameDomains` | `frame-src`: nested iframes |
89+
| `base_uri_domains` | `baseUriDomains` | `base-uri`: what `<base>` may point at |
90+
91+
`ResourcePermissions`: each field requests a browser permission for the iframe.
92+
93+
| Python | Wire (`_meta.ui.permissions`) |
94+
|---|---|
95+
| `camera` | `camera` |
96+
| `microphone` | `microphone` |
97+
| `geolocation` | `geolocation` |
98+
| `clipboard_write` | `clipboardWrite` |
99+
100+
!!! note
101+
CSP and permissions live on the **resource**, never on the tool. The spec's tool
102+
metadata has no slot for them, and hosts ignore them there. The SDK makes the
103+
mistake unrepresentable: `@apps.tool()` simply has no `csp` parameter.
104+
105+
### Visibility
106+
107+
`visibility=["app"]` on a tool says "this exists for the iframe, not the model":
108+
109+
* `"model"`: the model may call it.
110+
* `"app"`: the iframe may call it (via `callServerTool`).
111+
* Omitted: both, which is the default.
112+
113+
Filtering is the **host's** job. Your server lists app-only tools in `tools/list`
114+
like any other; the host hides them from the model. Don't filter server-side.
115+
116+
## The rules the SDK enforces
117+
118+
All of these fail at startup, not in production:
119+
120+
* A `resource_uri` or resource URI that isn't `ui://...` is a `ValueError` at
121+
decoration/registration time.
122+
* A tool bound to a URI with **no matching registered resource** is a `ValueError`
123+
when `MCPServer(extensions=[apps])` consumes the extension. A tool advertising
124+
HTML that 404s on `resources/read` is a misconfiguration, so it refuses to
125+
construct.
126+
* `meta={"ui": ...}` on `@apps.tool()` is a `ValueError`. The decorator owns
127+
`_meta["ui"]`; say it with `resource_uri=` and `visibility=`. Other `meta=` keys
128+
merge fine alongside.
129+
130+
Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd
131+
rather you find out before a host does.
132+
133+
## Beyond inline HTML
134+
135+
`add_html_resource` covers the common case: a string of HTML. For anything else,
136+
HTML on disk or generated content, build the resource yourself and hand it over:
137+
138+
```python title="server.py" hl_lines="12 18"
139+
--8<-- "docs_src/apps/tutorial003.py"
140+
```
141+
142+
`add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource
143+
doesn't set one explicitly, and rejects an explicit mismatch: a `ui://` resource
144+
under any other MIME type is one no host will render.
145+
146+
!!! tip
147+
Targeting a pre-GA host that still reads the deprecated flat
148+
`_meta["ui/resourceUri"]` key? Merge it yourself:
149+
`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`.
150+
The nested `ui` object is the spec shape; the flat key is on its way out.
151+
152+
## See it run
153+
154+
The `apps` story in `examples/stories/` is this page as a runnable pair: a server
155+
with a UI-bound clock tool and a client that negotiates Apps, reads the tool's
156+
`_meta.ui.resourceUri`, fetches the HTML, and calls the tool.
157+
158+
```bash
159+
uv run python -m stories.apps.client
160+
```

docs/advanced/extensions.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# Extensions
2+
3+
An **extension** is an opt-in bundle of MCP behaviour behind one identifier.
4+
5+
It can contribute tools, resources, and new request methods, and it can wrap `tools/call`.
6+
The server advertises it under `capabilities.extensions`, the client opts in the same way,
7+
and nothing changes for anyone who didn't ask for it. That is the contract (SEP-2133), and
8+
it has one golden rule: **extensions are off by default**.
9+
10+
## Using an extension
11+
12+
Pass instances at construction:
13+
14+
```python title="server.py"
15+
--8<-- "docs_src/extensions/tutorial001.py"
16+
```
17+
18+
Done. The server now advertises `io.modelcontextprotocol/ui` under
19+
`capabilities.extensions` and serves everything the extension contributes.
20+
21+
`Apps` is the built-in reference extension, and it gets its own page: **[MCP Apps](apps.md)**.
22+
23+
!!! note
24+
Extensions are fixed at construction. There is no `add_extension` to call later:
25+
a server's capability map should not change while clients are connected to it.
26+
27+
The capability map rides `server/discover`, which is a **2026-07-28** path. A legacy
28+
`initialize` handshake has nowhere to put it, so a legacy client simply doesn't see
29+
the extension. Design for that: an extension *augments* a server, it must not be the
30+
only way the server is usable.
31+
32+
## Writing your own
33+
34+
Subclass `Extension` and override only what you need. Every method has a default.
35+
36+
### The identifier
37+
38+
```python
39+
--8<-- "docs_src/extensions/tutorial002.py"
40+
```
41+
42+
The identifier is a `vendor-prefix/name` string following the spec's `_meta` key
43+
grammar: dot-separated labels (each starts with a letter, ends with a letter or
44+
digit), a slash, then the name. It is validated **when the class is defined**, so a
45+
typo doesn't wait for a server to boot:
46+
47+
```text
48+
TypeError: Stamps.identifier must be a `vendor-prefix/name` string
49+
(reverse-DNS prefix required), got 'stamps'
50+
```
51+
52+
Use a domain you control as the prefix. `io.modelcontextprotocol/*` is for extensions
53+
specified by the MCP project itself.
54+
55+
### Contributing tools
56+
57+
The smallest useful extension is one tool and a settings map:
58+
59+
```python title="server.py" hl_lines="17 19-20 22-23 26"
60+
--8<-- "docs_src/extensions/tutorial003.py"
61+
```
62+
63+
* `tools()` returns `ToolBinding`s. The server registers each one exactly as if you
64+
had called `mcp.add_tool(...)` yourself: same schema generation, same `Context`
65+
injection, same everything.
66+
* `settings()` is the value advertised at `capabilities.extensions["com.example/stamps"]`.
67+
Return `{}` (the default) to advertise the extension with no settings.
68+
* The extension never receives the server. It declares contributions as data;
69+
`MCPServer` consumes them. There is no `self.server` to mutate.
70+
71+
And `main()` is the proof, an in-memory client straight against `mcp`:
72+
73+
```python title="server.py" hl_lines="29-34"
74+
--8<-- "docs_src/extensions/tutorial003.py"
75+
```
76+
77+
### Serving your own methods
78+
79+
An extension can register **new request methods**: its own verbs, served next to the
80+
spec's:
81+
82+
```python title="server.py" hl_lines="15-21 30 39-47"
83+
--8<-- "docs_src/extensions/tutorial004.py"
84+
```
85+
86+
* `SearchParams` subclasses `RequestParams`, so the 2026 `_meta` envelope parses
87+
uniformly and your handler gets validated params, never a raw dict. Bound what
88+
the client controls: `Field(ge=1, le=100)` rejects an absurd `limit` before
89+
your code allocates anything for it.
90+
* `require_client_extension(ctx, EXTENSION_ID)` is the gate: a client that did not
91+
declare the extension gets the `-32021` (missing required client capability) error,
92+
with the machine-readable `requiredCapabilities` payload the spec asks for.
93+
* `protocol_versions=frozenset({"2026-07-28"})` pins the method to one wire version.
94+
At any other version the client gets `METHOD_NOT_FOUND`, exactly as if the method
95+
didn't exist there. For that client, it doesn't.
96+
97+
Methods are **strictly additive**. The SDK enforces this at construction, not at
98+
runtime:
99+
100+
* A `MethodBinding` for a spec-defined method (`tools/list`, `completion/complete`, ...)
101+
raises `ValueError` when the binding is constructed. Core verbs belong to the server.
102+
* Two extensions binding the same method raise when the second one registers.
103+
Last-write-wins is how plugins corrupt each other; we don't do that.
104+
* An empty `protocol_versions` set raises too: a method that can never be served
105+
is a bug, not a configuration.
106+
107+
### The client side
108+
109+
The same file's `main()` is the whole client story, both halves of it:
110+
111+
```python title="server.py" hl_lines="53-57"
112+
--8<-- "docs_src/extensions/tutorial004.py"
113+
```
114+
115+
* `Client(..., extensions={EXTENSION_ID: {}})` declares the extension. That map
116+
becomes `ClientCapabilities.extensions`: on a 2026-07-28 connection it travels in
117+
the per-request `_meta` envelope, so the server sees it on **every** request; on
118+
a legacy connection it rides the `initialize` handshake. Server code doesn't care
119+
which: `require_client_extension(ctx, ...)` and
120+
`ctx.session.check_client_capability(...)` read the right source on both paths.
121+
* Vendor methods drop one layer to `client.session.send_request(...)`; `Client`
122+
only grows first-class methods for spec verbs. The `cast` is there because
123+
`send_request` is typed against the spec's closed request union.
124+
125+
### Intercepting `tools/call`
126+
127+
The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit,
128+
or veto a tool call:
129+
130+
```python title="server.py" hl_lines="18-25"
131+
--8<-- "docs_src/extensions/tutorial005.py"
132+
```
133+
134+
* `params` is the validated `CallToolRequestParams`: you get `params.name` and
135+
`params.arguments` without touching raw JSON.
136+
* `call_next(ctx)` runs the rest of the chain. Return its result unchanged (observe),
137+
return something else (replace), or raise an `MCPError` (refuse).
138+
* With several extensions, interceptors nest in registration order: the first
139+
extension in `extensions=[...]` is outermost.
140+
* The default implementation is a pass-through, and a server whose extensions never
141+
override this hook installs **no** middleware at all. You don't pay for what
142+
you don't use.
143+
144+
The hook wraps `tools/call` and nothing else. For every-message concerns, use
145+
[Middleware](middleware.md). That is what it is for.
146+
147+
## What an extension cannot do
148+
149+
The contribution surface is **closed** on purpose: settings, tools, resources,
150+
methods, one `tools/call` interceptor. An extension cannot:
151+
152+
* **Reach into the server.** It declares data; it holds no server reference.
153+
* **Replace core behaviour.** Spec methods are rejected at construction, and
154+
`initialize` is reserved by the runner outright.
155+
* **Register late.** After `MCPServer(...)` returns, the extension set is what it is.
156+
157+
If you are fighting these walls, you are not writing an extension. You are writing
158+
a fork. The walls are the feature: a user reading `extensions=[Apps(), Stamps()]`
159+
knows *everything* those two can have touched.

docs/migration.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,50 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th
407407

408408
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.
409409

410+
### Server extensions API (SEP-2133)
411+
412+
`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a
413+
reverse-DNS identifier and advertise it under `ServerCapabilities.extensions`
414+
(the 2026-07-28 capability map). An extension subclasses `mcp.server.extension.Extension`
415+
and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()`
416+
(additive) and `intercept_tool_call()` (wraps `tools/call`). The `identifier` must be a
417+
`vendor-prefix/name` string following the spec's `_meta` key grammar; a class-level
418+
`identifier` is validated when the subclass is defined, one assigned in `__init__` when
419+
the extension is registered. Pass instances at construction:
420+
421+
```python
422+
from mcp.server.mcpserver import MCPServer
423+
from mcp.server.apps import Apps
424+
425+
mcp = MCPServer("demo", extensions=[Apps()])
426+
```
427+
428+
The reference extension is `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`):
429+
it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and
430+
`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback — `True` only
431+
when the client's ui-extension settings list the `text/html;profile=mcp-app`
432+
MIME type, per the Apps spec's required `mimeTypes` field. Every
433+
`@apps.tool(resource_uri=...)` must have a matching resource registered on the
434+
same `Apps` instance (`add_html_resource` for inline HTML, `add_resource` for a
435+
pre-built `Resource`); a tool bound to an unregistered URI raises at
436+
`MCPServer(...)` construction rather than 404ing on `resources/read` at runtime.
437+
438+
Extension methods are strictly additive: a `MethodBinding` cannot name a
439+
spec-defined request method, and registering one whose method collides with
440+
another handler raises at construction. A `MethodBinding` may set
441+
`protocol_versions` to scope an extension method to specific wire versions
442+
(`frozenset()` is rejected — use `None` to admit every version); a request at
443+
any other version is `METHOD_NOT_FOUND`. An
444+
extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, identifier)`
445+
to reject a request with the `-32021` (missing required client capability) error
446+
when the client did not declare the extension.
447+
448+
Clients advertise extension support with the new `Client(extensions=...)` /
449+
`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`.
450+
The extensions capability map is negotiated over `server/discover` (modern path);
451+
a legacy `initialize` handshake does not carry it. Extensions are off by default
452+
and never alter behaviour unless registered.
453+
410454
### `McpError` renamed to `MCPError`
411455

412456
The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.

docs_src/apps/__init__.py

Whitespace-only changes.

docs_src/apps/report.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<!doctype html>
2+
<title>Report</title>
3+
<p>Quarterly numbers render here.</p>

0 commit comments

Comments
 (0)