Skip to content

Commit efd373d

Browse files
committed
Add extensions and MCP Apps docs, an extensions story, and Apps polish
- docs/advanced/extensions.md: using and writing extensions (identifier grammar, contributions, vendor methods, version pinning, the tools/call interceptor, client-side declaration), backed by runnable docs_src examples with tests proving every claim - docs/advanced/apps.md: the two-part Apps model, graceful degradation and the meaningful-content rule, CSP/permissions field tables, visibility semantics, the construction-time checks, and the add_resource escape hatch - examples/stories/extensions: a custom extension end to end (settings entry, contributed tool, vendor method gated on the client declaring the extension back) - Apps.tool() rejects a caller-supplied 'ui' meta key instead of silently clobbering it; add_resource defaults the app MIME type and rejects an explicit mismatch; resource _meta.ui pinned on both list entry and read content item
1 parent bb9f793 commit efd373d

19 files changed

Lines changed: 882 additions & 6 deletions

File tree

docs/advanced/apps.md

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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="17 20 23-24 28 30"
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:
53+
54+
```python
55+
@apps.tool(resource_uri="ui://clock/app.html")
56+
def get_time(ctx: Context) -> str:
57+
now = current_time()
58+
if not client_supports_apps(ctx):
59+
return f"The time is {now}." # a sentence for humans without the UI
60+
return now # raw data the app renders
61+
```
62+
63+
`client_supports_apps(ctx)` is `True` only when the client declared the
64+
`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app`
65+
in its `mimeTypes` settings. The field is required, so a client that omits it
66+
does not count.
67+
68+
!!! warning
69+
Never return a placeholder like `"[Rendered UI]"` as the only content. If the
70+
fallback text is useless, the tool is useless to every text-only client and to
71+
the model itself. Write the sentence.
72+
73+
A client declares support like any extension capability:
74+
75+
```python
76+
from mcp import Client
77+
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
78+
79+
async with Client(target, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
80+
...
81+
```
82+
83+
## Locking the iframe down
84+
85+
The resource side carries the security metadata: what the iframe may load, which
86+
browser permissions it wants, how it would like to be framed:
87+
88+
```python title="server.py" hl_lines="9 19-22"
89+
--8<-- "docs_src/apps/tutorial002.py"
90+
```
91+
92+
`csp` and `permissions` are **requests to the host**, not server behaviour. The host
93+
builds the iframe's Content-Security-Policy and Permissions-Policy from them, and it
94+
may refuse. Feature-detect in your JS rather than assuming a grant.
95+
96+
`ResourceCsp`, field by field (Python name, wire key, what the host does with it):
97+
98+
| Python | Wire (`_meta.ui.csp`) | Controls |
99+
|---|---|---|
100+
| `connect_domains` | `connectDomains` | `connect-src`: where `fetch`/XHR may go |
101+
| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: static assets |
102+
| `frame_domains` | `frameDomains` | `frame-src`: nested iframes |
103+
| `base_uri_domains` | `baseUriDomains` | `base-uri`: what `<base>` may point at |
104+
105+
`ResourcePermissions`: each field requests a browser permission for the iframe.
106+
107+
| Python | Wire (`_meta.ui.permissions`) |
108+
|---|---|
109+
| `camera` | `camera` |
110+
| `microphone` | `microphone` |
111+
| `geolocation` | `geolocation` |
112+
| `clipboard_write` | `clipboardWrite` |
113+
114+
!!! note
115+
CSP and permissions live on the **resource**, never on the tool. The spec's tool
116+
metadata has no slot for them, and hosts ignore them there. The SDK makes the
117+
mistake unrepresentable: `@apps.tool()` simply has no `csp` parameter.
118+
119+
### Visibility
120+
121+
`visibility=["app"]` on a tool says "this exists for the iframe, not the model":
122+
123+
* `"model"`: the model may call it.
124+
* `"app"`: the iframe may call it (via `callServerTool`).
125+
* Omitted: both, which is the default.
126+
127+
Filtering is the **host's** job. Your server lists app-only tools in `tools/list`
128+
like any other; the host hides them from the model. Don't filter server-side.
129+
130+
## The rules the SDK enforces
131+
132+
All of these fail at startup, not in production:
133+
134+
* A `resource_uri` or resource URI that isn't `ui://...` is a `ValueError` at
135+
decoration/registration time.
136+
* A tool bound to a URI with **no matching registered resource** is a `ValueError`
137+
when `MCPServer(extensions=[apps])` consumes the extension. A tool advertising
138+
HTML that 404s on `resources/read` is a misconfiguration, so it refuses to
139+
construct.
140+
* `meta={"ui": ...}` on `@apps.tool()` is a `ValueError`. The decorator owns
141+
`_meta["ui"]`; say it with `resource_uri=` and `visibility=`. Other `meta=` keys
142+
merge fine alongside.
143+
144+
Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd
145+
rather you find out before a host does.
146+
147+
## Beyond inline HTML
148+
149+
`add_html_resource` covers the common case: a string of HTML. For anything else,
150+
HTML on disk or generated content, build the resource yourself and hand it over:
151+
152+
```python
153+
from mcp.server.mcpserver.resources import FileResource
154+
155+
apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=html_path))
156+
```
157+
158+
`add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource
159+
doesn't set one explicitly, and rejects an explicit mismatch: a `ui://` resource
160+
under any other MIME type is one no host will render.
161+
162+
!!! tip
163+
Targeting a pre-GA host that still reads the deprecated flat
164+
`_meta["ui/resourceUri"]` key? Merge it yourself:
165+
`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`.
166+
The nested `ui` object is the spec shape; the flat key is on its way out.
167+
168+
## See it run
169+
170+
The `apps` story in `examples/stories/` is this page as a runnable pair: a server
171+
with a UI-bound clock tool and a client that negotiates Apps, reads the tool's
172+
`_meta.ui.resourceUri`, fetches the HTML, and calls the tool.
173+
174+
```bash
175+
uv run python -m stories.apps.client
176+
```

docs/advanced/extensions.md

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

docs_src/apps/__init__.py

Whitespace-only changes.

docs_src/apps/tutorial001.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from mcp.server.apps import Apps, client_supports_apps
2+
from mcp.server.mcpserver import MCPServer
3+
from mcp.server.mcpserver.context import Context
4+
5+
CLOCK_HTML = """\
6+
<!doctype html>
7+
<title>Clock</title>
8+
<h1 id="now">...</h1>
9+
<script>
10+
window.addEventListener("message", (event) => {
11+
const text = event.data?.result?.content?.[0]?.text;
12+
if (text) document.getElementById("now").textContent = text;
13+
});
14+
</script>
15+
"""
16+
17+
apps = Apps()
18+
19+
20+
@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
21+
def get_time(ctx: Context) -> str:
22+
now = "2026-06-26T12:00:00Z"
23+
if not client_supports_apps(ctx):
24+
return f"The time is {now}."
25+
return now
26+
27+
28+
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")
29+
30+
mcp = MCPServer("clock", extensions=[apps])

0 commit comments

Comments
 (0)