Add a pluggable server extension API with MCP Apps (#3003)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
fe9723853d
commit
4b519782f1
39 changed files with 2598 additions and 24 deletions
160
docs/advanced/apps.md
Normal file
160
docs/advanced/apps.md
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
# MCP Apps
|
||||||
|
|
||||||
|
An **MCP App** is a tool with a face: alongside its data, the tool points at an HTML
|
||||||
|
document the host renders as an interactive surface.
|
||||||
|
|
||||||
|
Two parts, always two parts:
|
||||||
|
|
||||||
|
1. **A tool** that does the work and returns data, like any other tool.
|
||||||
|
2. **A `ui://` resource** containing the HTML the host shows for it.
|
||||||
|
|
||||||
|
The tool carries a `_meta.ui.resourceUri` reference to the resource. The host fetches
|
||||||
|
it with `resources/read`, renders it in a **sandboxed iframe**, and pushes the tool's
|
||||||
|
result into that iframe via `postMessage`. Your server never sends or receives any
|
||||||
|
`ui/*` messages: that traffic is between the host and the iframe. You serve a tool
|
||||||
|
and an HTML document; the host does the theater.
|
||||||
|
|
||||||
|
The SDK ships this as the built-in `Apps` extension (`io.modelcontextprotocol/ui`).
|
||||||
|
If [Extensions](extensions.md) are new to you, skim that page first. One minute,
|
||||||
|
then come back.
|
||||||
|
|
||||||
|
## A clock with a face
|
||||||
|
|
||||||
|
```python title="server.py" hl_lines="18 21 29 31"
|
||||||
|
--8<-- "docs_src/apps/tutorial001.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
Four moves:
|
||||||
|
|
||||||
|
* `Apps()`: one instance holds your UI-bound tools and their resources.
|
||||||
|
* `@apps.tool(resource_uri="ui://clock/app.html")`: a regular tool, plus the
|
||||||
|
`_meta.ui.resourceUri` stamp. Everything `@mcp.tool()` accepts (name, title,
|
||||||
|
description, ...) passes through.
|
||||||
|
* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: the matching
|
||||||
|
resource, served as `text/html;profile=mcp-app`. That exact MIME type is what
|
||||||
|
tells a host "this is an app, render it".
|
||||||
|
* `MCPServer("clock", extensions=[apps])`: opt in. The server now advertises
|
||||||
|
`io.modelcontextprotocol/ui` under `capabilities.extensions`.
|
||||||
|
|
||||||
|
The HTML itself listens for the host's `postMessage` and shows the result. For real
|
||||||
|
apps, use the official [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps)
|
||||||
|
browser SDK inside your HTML. It gives you `ontoolresult`, `callServerTool`,
|
||||||
|
`getHostContext`, and `onhostcontextchanged` instead of raw message events.
|
||||||
|
|
||||||
|
## Graceful degradation
|
||||||
|
|
||||||
|
Not every client renders apps. The spec is blunt about what that means for you:
|
||||||
|
|
||||||
|
> Tools **MUST** return a meaningful `content` array even when UI is available.
|
||||||
|
|
||||||
|
The model reads `content`; the iframe is for humans. A UI-capable host still feeds
|
||||||
|
the text result to the model, and a text-only client gets *only* that. So the
|
||||||
|
canonical pattern is one tool, two answers. Look at `get_time` again:
|
||||||
|
|
||||||
|
```python title="server.py" hl_lines="22-26"
|
||||||
|
--8<-- "docs_src/apps/tutorial001.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
`client_supports_apps(ctx)` is `True` only when the client declared the
|
||||||
|
`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app`
|
||||||
|
in its `mimeTypes` settings. The field is required, so a client that omits it
|
||||||
|
does not count. That is exactly what `main()` in the same file declares: the
|
||||||
|
client half of the negotiation, and the rich answer comes back.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Never return a placeholder like `"[Rendered UI]"` as the only content. If the
|
||||||
|
fallback text is useless, the tool is useless to every text-only client and to
|
||||||
|
the model itself. Write the sentence.
|
||||||
|
|
||||||
|
## Locking the iframe down
|
||||||
|
|
||||||
|
The resource side carries the security metadata: what the iframe may load, which
|
||||||
|
browser permissions it wants, how it would like to be framed:
|
||||||
|
|
||||||
|
```python title="server.py" hl_lines="9 19-22"
|
||||||
|
--8<-- "docs_src/apps/tutorial002.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
`csp` and `permissions` are **requests to the host**, not server behaviour. The host
|
||||||
|
builds the iframe's Content-Security-Policy and Permissions-Policy from them, and it
|
||||||
|
may refuse. Feature-detect in your JS rather than assuming a grant.
|
||||||
|
|
||||||
|
`ResourceCsp`, field by field (Python name, wire key, what the host does with it):
|
||||||
|
|
||||||
|
| Python | Wire (`_meta.ui.csp`) | Controls |
|
||||||
|
|---|---|---|
|
||||||
|
| `connect_domains` | `connectDomains` | `connect-src`: where `fetch`/XHR may go |
|
||||||
|
| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: static assets |
|
||||||
|
| `frame_domains` | `frameDomains` | `frame-src`: nested iframes |
|
||||||
|
| `base_uri_domains` | `baseUriDomains` | `base-uri`: what `<base>` may point at |
|
||||||
|
|
||||||
|
`ResourcePermissions`: each field requests a browser permission for the iframe.
|
||||||
|
|
||||||
|
| Python | Wire (`_meta.ui.permissions`) |
|
||||||
|
|---|---|
|
||||||
|
| `camera` | `camera` |
|
||||||
|
| `microphone` | `microphone` |
|
||||||
|
| `geolocation` | `geolocation` |
|
||||||
|
| `clipboard_write` | `clipboardWrite` |
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
CSP and permissions live on the **resource**, never on the tool. The spec's tool
|
||||||
|
metadata has no slot for them, and hosts ignore them there. The SDK makes the
|
||||||
|
mistake unrepresentable: `@apps.tool()` simply has no `csp` parameter.
|
||||||
|
|
||||||
|
### Visibility
|
||||||
|
|
||||||
|
`visibility=["app"]` on a tool says "this exists for the iframe, not the model":
|
||||||
|
|
||||||
|
* `"model"`: the model may call it.
|
||||||
|
* `"app"`: the iframe may call it (via `callServerTool`).
|
||||||
|
* Omitted: both, which is the default.
|
||||||
|
|
||||||
|
Filtering is the **host's** job. Your server lists app-only tools in `tools/list`
|
||||||
|
like any other; the host hides them from the model. Don't filter server-side.
|
||||||
|
|
||||||
|
## The rules the SDK enforces
|
||||||
|
|
||||||
|
All of these fail at startup, not in production:
|
||||||
|
|
||||||
|
* A `resource_uri` or resource URI that isn't `ui://...` is a `ValueError` at
|
||||||
|
decoration/registration time.
|
||||||
|
* A tool bound to a URI with **no matching registered resource** is a `ValueError`
|
||||||
|
when `MCPServer(extensions=[apps])` consumes the extension. A tool advertising
|
||||||
|
HTML that 404s on `resources/read` is a misconfiguration, so it refuses to
|
||||||
|
construct.
|
||||||
|
* `meta={"ui": ...}` on `@apps.tool()` is a `ValueError`. The decorator owns
|
||||||
|
`_meta["ui"]`; say it with `resource_uri=` and `visibility=`. Other `meta=` keys
|
||||||
|
merge fine alongside.
|
||||||
|
|
||||||
|
Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd
|
||||||
|
rather you find out before a host does.
|
||||||
|
|
||||||
|
## Beyond inline HTML
|
||||||
|
|
||||||
|
`add_html_resource` covers the common case: a string of HTML. For anything else,
|
||||||
|
HTML on disk or generated content, build the resource yourself and hand it over:
|
||||||
|
|
||||||
|
```python title="server.py" hl_lines="12 18"
|
||||||
|
--8<-- "docs_src/apps/tutorial003.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
`add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource
|
||||||
|
doesn't set one explicitly, and rejects an explicit mismatch: a `ui://` resource
|
||||||
|
under any other MIME type is one no host will render.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
Targeting a pre-GA host that still reads the deprecated flat
|
||||||
|
`_meta["ui/resourceUri"]` key? Merge it yourself:
|
||||||
|
`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`.
|
||||||
|
The nested `ui` object is the spec shape; the flat key is on its way out.
|
||||||
|
|
||||||
|
## See it run
|
||||||
|
|
||||||
|
The `apps` story in `examples/stories/` is this page as a runnable pair: a server
|
||||||
|
with a UI-bound clock tool and a client that negotiates Apps, reads the tool's
|
||||||
|
`_meta.ui.resourceUri`, fetches the HTML, and calls the tool.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m stories.apps.client
|
||||||
|
```
|
||||||
159
docs/advanced/extensions.md
Normal file
159
docs/advanced/extensions.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -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.
|
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`
|
### `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.
|
The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK.
|
||||||
|
|
|
||||||
0
docs_src/apps/__init__.py
Normal file
0
docs_src/apps/__init__.py
Normal file
3
docs_src/apps/report.html
Normal file
3
docs_src/apps/report.html
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<!doctype html>
|
||||||
|
<title>Report</title>
|
||||||
|
<p>Quarterly numbers render here.</p>
|
||||||
38
docs_src/apps/tutorial001.py
Normal file
38
docs_src/apps/tutorial001.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
from mcp import Client
|
||||||
|
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
from mcp.server.mcpserver.context import Context
|
||||||
|
|
||||||
|
CLOCK_HTML = """\
|
||||||
|
<!doctype html>
|
||||||
|
<title>Clock</title>
|
||||||
|
<h1 id="now">...</h1>
|
||||||
|
<script>
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
const text = event.data?.result?.content?.[0]?.text;
|
||||||
|
if (text) document.getElementById("now").textContent = text;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
|
||||||
|
apps = Apps()
|
||||||
|
|
||||||
|
|
||||||
|
@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
|
||||||
|
def get_time(ctx: Context) -> str:
|
||||||
|
now = "2026-06-26T12:00:00Z"
|
||||||
|
if not client_supports_apps(ctx):
|
||||||
|
return f"The time is {now}."
|
||||||
|
return now
|
||||||
|
|
||||||
|
|
||||||
|
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")
|
||||||
|
|
||||||
|
mcp = MCPServer("clock", extensions=[apps])
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
|
||||||
|
result = await client.call_tool("get_time", {})
|
||||||
|
print(result.content)
|
||||||
|
# [TextContent(text='2026-06-26T12:00:00Z')]
|
||||||
25
docs_src/apps/tutorial002.py
Normal file
25
docs_src/apps/tutorial002.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from mcp.server.apps import Apps, ResourceCsp, ResourcePermissions
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
|
||||||
|
DASHBOARD_HTML = "<!doctype html><title>Dashboard</title><canvas id='chart'></canvas>"
|
||||||
|
|
||||||
|
apps = Apps()
|
||||||
|
|
||||||
|
|
||||||
|
@apps.tool(resource_uri="ui://dashboard/app.html", visibility=["app"])
|
||||||
|
def refresh_dashboard() -> str:
|
||||||
|
"""Refresh the dashboard data."""
|
||||||
|
return "refreshed"
|
||||||
|
|
||||||
|
|
||||||
|
apps.add_html_resource(
|
||||||
|
"ui://dashboard/app.html",
|
||||||
|
DASHBOARD_HTML,
|
||||||
|
title="Dashboard",
|
||||||
|
csp=ResourceCsp(connect_domains=["https://api.example.com"]),
|
||||||
|
permissions=ResourcePermissions(clipboard_write={}),
|
||||||
|
domain="dashboard.example.com",
|
||||||
|
prefers_border=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mcp = MCPServer("dashboard", extensions=[apps])
|
||||||
20
docs_src/apps/tutorial003.py
Normal file
20
docs_src/apps/tutorial003.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from mcp.server.apps import Apps
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
from mcp.server.mcpserver.resources import FileResource
|
||||||
|
|
||||||
|
REPORT_HTML = Path(__file__).parent / "report.html"
|
||||||
|
|
||||||
|
apps = Apps()
|
||||||
|
|
||||||
|
|
||||||
|
@apps.tool(resource_uri="ui://report/app.html")
|
||||||
|
def refresh_report() -> str:
|
||||||
|
"""Refresh the report data."""
|
||||||
|
return "report refreshed"
|
||||||
|
|
||||||
|
|
||||||
|
apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=REPORT_HTML))
|
||||||
|
|
||||||
|
mcp = MCPServer("report", extensions=[apps])
|
||||||
0
docs_src/extensions/__init__.py
Normal file
0
docs_src/extensions/__init__.py
Normal file
4
docs_src/extensions/tutorial001.py
Normal file
4
docs_src/extensions/tutorial001.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from mcp.server.apps import Apps
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
|
||||||
|
mcp = MCPServer("demo", extensions=[Apps()])
|
||||||
5
docs_src/extensions/tutorial002.py
Normal file
5
docs_src/extensions/tutorial002.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from mcp.server.extension import Extension
|
||||||
|
|
||||||
|
|
||||||
|
class Stamps(Extension):
|
||||||
|
identifier = "com.example/stamps"
|
||||||
35
docs_src/extensions/tutorial003.py
Normal file
35
docs_src/extensions/tutorial003.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp import Client
|
||||||
|
from mcp.server.extension import Extension, ToolBinding
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
|
||||||
|
|
||||||
|
def stamp(text: str) -> str:
|
||||||
|
"""Stamp a message with the office seal."""
|
||||||
|
return f"[stamped] {text}"
|
||||||
|
|
||||||
|
|
||||||
|
class Stamps(Extension):
|
||||||
|
"""A purely additive extension: one tool, one capability entry."""
|
||||||
|
|
||||||
|
identifier = "com.example/stamps"
|
||||||
|
|
||||||
|
def settings(self) -> dict[str, Any]:
|
||||||
|
return {"sealed": True}
|
||||||
|
|
||||||
|
def tools(self) -> Sequence[ToolBinding]:
|
||||||
|
return [ToolBinding(fn=stamp)]
|
||||||
|
|
||||||
|
|
||||||
|
mcp = MCPServer("post-office", extensions=[Stamps()])
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
print(client.server_capabilities.extensions)
|
||||||
|
# {'com.example/stamps': {'sealed': True}}
|
||||||
|
result = await client.call_tool("stamp", {"text": "hello"})
|
||||||
|
print(result.content)
|
||||||
|
# [TextContent(text='[stamped] hello')]
|
||||||
58
docs_src/extensions/tutorial004.py
Normal file
58
docs_src/extensions/tutorial004.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
|
import mcp_types as types
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from mcp import Client
|
||||||
|
from mcp.server.context import ServerRequestContext
|
||||||
|
from mcp.server.extension import Extension, MethodBinding
|
||||||
|
from mcp.server.mcpserver import MCPServer, require_client_extension
|
||||||
|
|
||||||
|
EXTENSION_ID = "com.example/search"
|
||||||
|
|
||||||
|
|
||||||
|
class SearchParams(types.RequestParams):
|
||||||
|
query: str
|
||||||
|
limit: int = Field(default=10, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResult(types.Result):
|
||||||
|
items: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]):
|
||||||
|
method: Literal["com.example/search"] = "com.example/search"
|
||||||
|
params: SearchParams
|
||||||
|
|
||||||
|
|
||||||
|
async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult:
|
||||||
|
require_client_extension(ctx, EXTENSION_ID)
|
||||||
|
return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)])
|
||||||
|
|
||||||
|
|
||||||
|
class Search(Extension):
|
||||||
|
"""An extension that serves its own request method."""
|
||||||
|
|
||||||
|
identifier = EXTENSION_ID
|
||||||
|
|
||||||
|
def methods(self) -> Sequence[MethodBinding]:
|
||||||
|
return [
|
||||||
|
MethodBinding(
|
||||||
|
"com.example/search",
|
||||||
|
SearchParams,
|
||||||
|
search,
|
||||||
|
protocol_versions=frozenset({"2026-07-28"}),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
mcp = MCPServer("catalog", extensions=[Search()])
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with Client(mcp, extensions={EXTENSION_ID: {}}) as client:
|
||||||
|
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
|
||||||
|
result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
|
||||||
|
print(result.items)
|
||||||
|
# ['mcp-0', 'mcp-1', 'mcp-2']
|
||||||
34
docs_src/extensions/tutorial005.py
Normal file
34
docs_src/extensions/tutorial005.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp_types import CallToolRequestParams
|
||||||
|
|
||||||
|
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
|
||||||
|
from mcp.server.extension import Extension
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Extension):
|
||||||
|
"""Observe every tools/call without touching its result."""
|
||||||
|
|
||||||
|
identifier = "com.example/audit"
|
||||||
|
|
||||||
|
async def intercept_tool_call(
|
||||||
|
self,
|
||||||
|
params: CallToolRequestParams,
|
||||||
|
ctx: ServerRequestContext[Any, Any],
|
||||||
|
call_next: CallNext,
|
||||||
|
) -> HandlerResult:
|
||||||
|
logger.info("tool %r called", params.name)
|
||||||
|
return await call_next(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
mcp = MCPServer("audited", extensions=[AuditLog()])
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def add(a: int, b: int) -> int:
|
||||||
|
"""Add two numbers."""
|
||||||
|
return a + b
|
||||||
|
|
@ -1,14 +1,40 @@
|
||||||
# apps
|
# apps
|
||||||
|
|
||||||
MCP Apps: a tool result carries a `_meta.ui` reference to a `ui://` resource
|
MCP Apps: a tool carries a `_meta.ui.resourceUri` reference to a `ui://`
|
||||||
that the host renders as an interactive surface. The story will register a
|
resource that the host renders as an interactive surface. The server opts in via
|
||||||
`@ui` resource and return it from a tool.
|
the `Apps` extension (`io.modelcontextprotocol/ui`); the client negotiates it by
|
||||||
|
advertising the `text/html;profile=mcp-app` MIME type.
|
||||||
|
|
||||||
**Status: not yet implemented** ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)).
|
## Run it
|
||||||
The `extensions` capability map is not yet surfaced on `MCPServer`, so a server
|
|
||||||
cannot advertise Apps support and a client cannot negotiate it.
|
```bash
|
||||||
|
# stdio (default — the client spawns the server as a subprocess)
|
||||||
|
uv run python -m stories.apps.client
|
||||||
|
|
||||||
|
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
|
||||||
|
uv run python -m stories.apps.client --http
|
||||||
|
```
|
||||||
|
|
||||||
|
## What to look at
|
||||||
|
|
||||||
|
- `server.py` `MCPServer("apps-example", extensions=[apps])` — the extension
|
||||||
|
advertises `io.modelcontextprotocol/ui` under `ServerCapabilities.extensions`
|
||||||
|
and contributes the UI-bound tool and its `ui://` resource. `MCPServer` itself
|
||||||
|
never learns about "ui"; it applies a closed set of contributions.
|
||||||
|
- `server.py` `@apps.tool(resource_uri=...)` — stamps `_meta.ui.resourceUri` on
|
||||||
|
the tool; `add_html_resource` registers the matching `ui://` resource at
|
||||||
|
`text/html;profile=mcp-app`.
|
||||||
|
- `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a
|
||||||
|
client that did not negotiate Apps gets a text-only result.
|
||||||
|
- `client.py` `Client(target, extensions={...})` — the client advertises Apps
|
||||||
|
support so the server returns the UI-enabled result, then reads the tool's
|
||||||
|
`_meta.ui.resourceUri` and fetches that resource.
|
||||||
|
|
||||||
## Spec
|
## Spec
|
||||||
|
|
||||||
[MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps)
|
[MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps)
|
||||||
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
|
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
`custom_methods/` (registering a non-spec method without an extension).
|
||||||
|
|
|
||||||
0
examples/stories/apps/__init__.py
Normal file
0
examples/stories/apps/__init__.py
Normal file
35
examples/stories/apps/client.py
Normal file
35
examples/stories/apps/client.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Negotiate MCP Apps, discover a tool's `ui://` UI, fetch it, and call the tool."""
|
||||||
|
|
||||||
|
from mcp_types import TextContent, TextResourceContents
|
||||||
|
|
||||||
|
from mcp.client import Client
|
||||||
|
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
|
||||||
|
from stories._harness import Target, run_client
|
||||||
|
|
||||||
|
|
||||||
|
async def main(target: Target, *, mode: str = "auto") -> None:
|
||||||
|
# Advertise MCP Apps support so the server returns the UI-enabled result; a
|
||||||
|
# client that omits this gets the text-only fallback (graceful degradation).
|
||||||
|
async with Client(target, mode=mode, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
|
||||||
|
# The extensions capability map rides `server/discover` (modern only). On a
|
||||||
|
# legacy connection (today's stdio) it is absent, so assert it only when present.
|
||||||
|
if client.server_capabilities.extensions is not None:
|
||||||
|
assert client.server_capabilities.extensions == {EXTENSION_ID: {}}, client.server_capabilities.extensions
|
||||||
|
|
||||||
|
listed = await client.list_tools()
|
||||||
|
tool = next(t for t in listed.tools if t.name == "get_time")
|
||||||
|
assert tool.meta is not None, tool
|
||||||
|
assert tool.meta["ui"]["resourceUri"] == "ui://get-time/app.html", tool.meta
|
||||||
|
|
||||||
|
ui = await client.read_resource("ui://get-time/app.html")
|
||||||
|
contents = ui.contents[0]
|
||||||
|
assert isinstance(contents, TextResourceContents)
|
||||||
|
assert contents.mime_type == APP_MIME_TYPE, contents.mime_type
|
||||||
|
|
||||||
|
result = await client.call_tool("get_time", {})
|
||||||
|
assert isinstance(result.content[0], TextContent)
|
||||||
|
assert result.content[0].text == "2026-06-26T00:00:00Z", result.content[0].text
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_client(main)
|
||||||
43
examples/stories/apps/server.py
Normal file
43
examples/stories/apps/server.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""MCP Apps: a tool bound to a `ui://` resource the host renders as an interactive surface.
|
||||||
|
|
||||||
|
`Apps` is an opt-in `Extension` passed to `MCPServer(extensions=[...])`. The
|
||||||
|
`@apps.tool(resource_uri=...)` decorator stamps `_meta.ui.resourceUri` onto the
|
||||||
|
tool; `add_html_resource` registers the matching `ui://` HTML resource. The tool
|
||||||
|
degrades gracefully: `client_supports_apps(ctx)` reports whether the client
|
||||||
|
negotiated Apps, so it returns text-only output otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from mcp.server.apps import Apps, client_supports_apps
|
||||||
|
from mcp.server.mcpserver import MCPServer
|
||||||
|
from mcp.server.mcpserver.context import Context
|
||||||
|
from stories._hosting import run_server_from_args
|
||||||
|
|
||||||
|
RESOURCE_URI = "ui://get-time/app.html"
|
||||||
|
CLOCK_HTML = """<!doctype html>
|
||||||
|
<title>Current time</title>
|
||||||
|
<h1 id="now">…</h1>
|
||||||
|
<script>
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
const text = event.data?.result?.content?.[0]?.text;
|
||||||
|
if (text) document.getElementById("now").textContent = text;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_server() -> MCPServer:
|
||||||
|
apps = Apps()
|
||||||
|
|
||||||
|
@apps.tool(resource_uri=RESOURCE_URI, title="Get Time", description="Return the current time.")
|
||||||
|
def get_time(ctx: Context) -> str:
|
||||||
|
now = "2026-06-26T00:00:00Z"
|
||||||
|
if not client_supports_apps(ctx):
|
||||||
|
return f"The time is {now}."
|
||||||
|
return now
|
||||||
|
|
||||||
|
apps.add_html_resource(RESOURCE_URI, CLOCK_HTML, title="Clock")
|
||||||
|
return MCPServer("apps-example", extensions=[apps])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_server_from_args(build_server)
|
||||||
41
examples/stories/extensions/README.md
Normal file
41
examples/stories/extensions/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# extensions
|
||||||
|
|
||||||
|
Writing your own extension (SEP-2133): one identifier bundles a settings entry
|
||||||
|
under `ServerCapabilities.extensions`, a contributed tool, and a vendor request
|
||||||
|
method gated on the client declaring the extension back.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# stdio (default — the client spawns the server as a subprocess)
|
||||||
|
uv run python -m stories.extensions.client
|
||||||
|
|
||||||
|
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
|
||||||
|
uv run python -m stories.extensions.client --http
|
||||||
|
```
|
||||||
|
|
||||||
|
## What to look at
|
||||||
|
|
||||||
|
- `server.py` `class Catalog(Extension)` — the whole extension: `settings()`
|
||||||
|
becomes the advertised capability entry, `tools()` contributes a regular tool,
|
||||||
|
`methods()` registers a vendor verb. The extension never holds the server; it
|
||||||
|
declares contributions and `MCPServer(extensions=[...])` consumes them.
|
||||||
|
- `server.py` `require_client_extension(ctx, EXTENSION_ID)` — the vendor method
|
||||||
|
rejects clients that did not declare the extension with `-32021` (missing
|
||||||
|
required client capability) and a machine-readable `requiredCapabilities`
|
||||||
|
payload.
|
||||||
|
- `client.py` `Client(target, extensions={EXTENSION_ID: {}})` — the client-side
|
||||||
|
half of the negotiation; on 2026-07-28 it travels in the per-request `_meta`
|
||||||
|
envelope.
|
||||||
|
- `client.py` `client.session.send_request(...)` — vendor methods have no
|
||||||
|
`Client`-level helper; the session escape hatch sends them.
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
|
||||||
|
[SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
|
||||||
|
· [Capabilities — `_meta` key grammar](https://modelcontextprotocol.io/specification/draft/basic/index)
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
`apps/` (the built-in MCP Apps extension) · `custom_methods/` (the same verb
|
||||||
|
registered on the lowlevel `Server` by hand, without an extension).
|
||||||
0
examples/stories/extensions/__init__.py
Normal file
0
examples/stories/extensions/__init__.py
Normal file
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue