diff --git a/docs/advanced/apps.md b/docs/advanced/apps.md new file mode 100644 index 000000000..87e260f01 --- /dev/null +++ b/docs/advanced/apps.md @@ -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 `` 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 +``` diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md new file mode 100644 index 000000000..6ca164228 --- /dev/null +++ b/docs/advanced/extensions.md @@ -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. diff --git a/docs/migration.md b/docs/migration.md index 42d420bf0..d94db1f60 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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-` 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. diff --git a/docs_src/apps/__init__.py b/docs_src/apps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docs_src/apps/report.html b/docs_src/apps/report.html new file mode 100644 index 000000000..7c94deefe --- /dev/null +++ b/docs_src/apps/report.html @@ -0,0 +1,3 @@ + +Report +

Quarterly numbers render here.

diff --git a/docs_src/apps/tutorial001.py b/docs_src/apps/tutorial001.py new file mode 100644 index 000000000..79721c597 --- /dev/null +++ b/docs_src/apps/tutorial001.py @@ -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 = """\ + +Clock +

...

+ +""" + +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')] diff --git a/docs_src/apps/tutorial002.py b/docs_src/apps/tutorial002.py new file mode 100644 index 000000000..11393285b --- /dev/null +++ b/docs_src/apps/tutorial002.py @@ -0,0 +1,25 @@ +from mcp.server.apps import Apps, ResourceCsp, ResourcePermissions +from mcp.server.mcpserver import MCPServer + +DASHBOARD_HTML = "Dashboard" + +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]) diff --git a/docs_src/apps/tutorial003.py b/docs_src/apps/tutorial003.py new file mode 100644 index 000000000..e3aed3ef7 --- /dev/null +++ b/docs_src/apps/tutorial003.py @@ -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]) diff --git a/docs_src/extensions/__init__.py b/docs_src/extensions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docs_src/extensions/tutorial001.py b/docs_src/extensions/tutorial001.py new file mode 100644 index 000000000..1e5a1f907 --- /dev/null +++ b/docs_src/extensions/tutorial001.py @@ -0,0 +1,4 @@ +from mcp.server.apps import Apps +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("demo", extensions=[Apps()]) diff --git a/docs_src/extensions/tutorial002.py b/docs_src/extensions/tutorial002.py new file mode 100644 index 000000000..87b59bd23 --- /dev/null +++ b/docs_src/extensions/tutorial002.py @@ -0,0 +1,5 @@ +from mcp.server.extension import Extension + + +class Stamps(Extension): + identifier = "com.example/stamps" diff --git a/docs_src/extensions/tutorial003.py b/docs_src/extensions/tutorial003.py new file mode 100644 index 000000000..312371bee --- /dev/null +++ b/docs_src/extensions/tutorial003.py @@ -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')] diff --git a/docs_src/extensions/tutorial004.py b/docs_src/extensions/tutorial004.py new file mode 100644 index 000000000..4a0a022af --- /dev/null +++ b/docs_src/extensions/tutorial004.py @@ -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'] diff --git a/docs_src/extensions/tutorial005.py b/docs_src/extensions/tutorial005.py new file mode 100644 index 000000000..61ec6c76b --- /dev/null +++ b/docs_src/extensions/tutorial005.py @@ -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 diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md index b802525fa..dc180a0d3 100644 --- a/examples/stories/apps/README.md +++ b/examples/stories/apps/README.md @@ -1,14 +1,40 @@ # apps -MCP Apps: a tool result carries a `_meta.ui` reference to a `ui://` resource -that the host renders as an interactive surface. The story will register a -`@ui` resource and return it from a tool. +MCP Apps: a tool carries a `_meta.ui.resourceUri` reference to a `ui://` +resource that the host renders as an interactive surface. The server opts in via +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)). -The `extensions` capability map is not yet surfaced on `MCPServer`, so a server -cannot advertise Apps support and a client cannot negotiate it. +## Run 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 [MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps) · [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) + +## See also + +`custom_methods/` (registering a non-spec method without an extension). diff --git a/examples/stories/apps/__init__.py b/examples/stories/apps/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/apps/client.py b/examples/stories/apps/client.py new file mode 100644 index 000000000..8a238f469 --- /dev/null +++ b/examples/stories/apps/client.py @@ -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) diff --git a/examples/stories/apps/server.py b/examples/stories/apps/server.py new file mode 100644 index 000000000..74d412e02 --- /dev/null +++ b/examples/stories/apps/server.py @@ -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 = """ +Current time +

+ +""" + + +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) diff --git a/examples/stories/extensions/README.md b/examples/stories/extensions/README.md new file mode 100644 index 000000000..6d3da72c9 --- /dev/null +++ b/examples/stories/extensions/README.md @@ -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). diff --git a/examples/stories/extensions/__init__.py b/examples/stories/extensions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/extensions/client.py b/examples/stories/extensions/client.py new file mode 100644 index 000000000..d3aacc140 --- /dev/null +++ b/examples/stories/extensions/client.py @@ -0,0 +1,54 @@ +"""Discover an extension's capability entry, call its tool, then send its vendor method.""" + +from typing import Literal, cast + +import mcp_types as types +from mcp_types import TextContent + +from mcp.client import Client +from stories._harness import Target, run_client + +EXTENSION_ID = "com.example/catalog" + + +class SearchParams(types.RequestParams): + query: str + limit: int = 3 + + +class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]): + method: Literal["com.example/search"] = "com.example/search" + params: SearchParams + + +class SearchResult(types.Result): + items: list[str] + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Declare the extension client-side so the server's `require_client_extension` + # gate on `com.example/search` passes. + async with Client(target, mode=mode, extensions={EXTENSION_ID: {}}) as client: + # The extensions capability map rides `server/discover` (modern only). On a + # legacy connection it is absent, so assert it only when present. + if client.server_capabilities.extensions is not None: + assert client.server_capabilities.extensions == {EXTENSION_ID: {"suggest": True}}, ( + client.server_capabilities.extensions + ) + + # The extension's tool is a regular tool: listed and callable like any other. + listed = await client.list_tools() + assert [tool.name for tool in listed.tools] == ["suggest"], listed + result = await client.call_tool("suggest", {"prefix": "mcp"}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "mcp-suggestion", result.content[0].text + + # Vendor methods drop one layer to `client.session` (see custom_methods/); + # the cast is needed because `send_request` is typed against the spec union. + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + found = await client.session.send_request(cast("types.ClientRequest", request), SearchResult) + assert found.items == ["mcp-0", "mcp-1", "mcp-2"], found + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/extensions/server.py b/examples/stories/extensions/server.py new file mode 100644 index 000000000..837c668dc --- /dev/null +++ b/examples/stories/extensions/server.py @@ -0,0 +1,64 @@ +"""Package a vendor verb and a tool as a reusable, advertised extension (SEP-2133). + +`custom_methods/` registers a verb on the lowlevel `Server` by hand; this story +bundles the same idea as an `Extension`: declared contributions, a settings entry +under `ServerCapabilities.extensions`, and a `require_client_extension` gate on +the vendor method. +""" + +from collections.abc import Sequence +from typing import Any + +import mcp_types as types +from pydantic import Field + +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, MethodBinding, ToolBinding +from mcp.server.mcpserver import MCPServer, require_client_extension +from stories._hosting import run_server_from_args + +EXTENSION_ID = "com.example/catalog" + + +class SearchParams(types.RequestParams): + """Subclass `RequestParams` so `_meta` (and the 2026 envelope keys) parse uniformly.""" + + query: str + limit: int = Field(default=3, ge=1, le=25) + + +class SearchResult(types.Result): + items: list[str] + + +def suggest(prefix: str) -> str: + """Suggest a catalog entry for a prefix.""" + return f"{prefix}-suggestion" + + +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 Catalog(Extension): + """One identifier, three contributions: settings, a tool, a vendor method.""" + + identifier = EXTENSION_ID + + def settings(self) -> dict[str, Any]: + return {"suggest": True} + + def tools(self) -> Sequence[ToolBinding]: + return [ToolBinding(fn=suggest)] + + def methods(self) -> Sequence[MethodBinding]: + return [MethodBinding("com.example/search", SearchParams, search)] + + +def build_server() -> MCPServer: + return MCPServer("extensions-example", extensions=[Catalog()]) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index fb688d294..c89f8a8c5 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -48,6 +48,21 @@ status = "deprecated" [story.custom_methods] lowlevel = false +[story.apps] +# Extension API is MCPServer-tier (Apps decorators + extensions=[...]); no lowlevel variant. +# The extensions capability map (SEP-2133) rides server/discover, a modern-only path, so +# `main` pins "auto" (legacy initialize cannot carry it) and the leg is http-asgi. +lowlevel = false +transports = ["in-memory", "http-asgi"] +era = "dual-in-body" + +[story.extensions] +# Same constraints as `apps`: MCPServer-tier extension API, capability map rides +# server/discover (modern only), client guards the capability assert by presence. +lowlevel = false +transports = ["in-memory", "http-asgi"] +era = "dual-in-body" + [story.schema_validators] [story.middleware] @@ -142,7 +157,6 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8 [deferred] caching = "client honouring + per-result override unlanded" subscriptions = "#2901 — Client.listen / ServerEventBus" -tasks = "extensions capability map + tasks runtime" -apps = "#2896 — extensions capability map" +tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)" skills = "#2896 — SEP-2640" events = "#2901 + #2896" diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md index ef15ae63f..d1956d1e3 100644 --- a/examples/stories/tasks/README.md +++ b/examples/stories/tasks/README.md @@ -1,16 +1,24 @@ # tasks -The `io.modelcontextprotocol/tasks` extension: long-running work registered -with `@task`, polled via `tasks/get`, updated mid-flight, and cancelled with -`tasks/cancel`. The story will show a task that outlives the request that -started it. +Task-augmented execution: a requestor augments a `tools/call` with a `task`, the +receiver returns a `CreateTaskResult` immediately, and the requestor polls +`tasks/get` and retrieves the deferred result. -**Status: not yet implemented.** The extension types exist but the `extensions` -capability map is not yet surfaced on `MCPServer`, and the runtime trails the -release. The TypeScript SDK deliberately removed its tasks example pending the -same work. +**Status: deferred.** Tasks ship in 2026-07-28 as +[SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md), +an `io.modelcontextprotocol/tasks` extension that is wire-incompatible with the +2025-11-25 in-core design still carried (types-only) in `mcp_types`. The runtime +needs to be built to the SEP — server-decided augmentation (ignoring the legacy +`params.task`), the `{tasks/get, tasks/update, tasks/cancel}` method set, the +`resultType: "task"` envelope, `execution.taskSupport` gating, and `ttlMs` +fields — so it lands in a separate PR with the conformance `tasks-*` scenarios +wired in. ## Spec -[Tasks — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks) +[SEP-2663 — Tasks extension](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md) · [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) + +## See also + +`apps/` (the additive half of the extension API). diff --git a/mkdocs.yml b/mkdocs.yml index 93127a410..e22e47e1d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,8 @@ nav: - URI templates: advanced/uri-templates.md - Pagination: advanced/pagination.md - Middleware: advanced/middleware.md + - Extensions: advanced/extensions.md + - MCP Apps: advanced/apps.md - OpenTelemetry: advanced/opentelemetry.md - Authorization: advanced/authorization.md - OAuth clients: advanced/oauth-clients.md diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index d6a6e4caa..d3290f308 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -217,6 +217,10 @@ async def main(): `read_resource` give up. Use `client.session.(..., allow_input_required=True)` to drive the loop manually instead.""" + extensions: dict[str, dict[str, Any]] | None = None + """SEP-2133 extension support to advertise under `ClientCapabilities.extensions` + (identifier -> settings), e.g. `{"io.modelcontextprotocol/ui": {"mimeTypes": [...]}}`.""" + _entered: bool = field(init=False, default=False) _session: ClientSession | None = field(init=False, default=None) _exit_stack: AsyncExitStack | None = field(init=False, default=None) @@ -255,6 +259,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession: message_handler=self.message_handler, client_info=self.client_info, elicitation_callback=self.elicitation_callback, + extensions=self.extensions, ) async def __aenter__(self) -> Client: diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index fa71d1330..3cebb569e 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -224,12 +224,14 @@ def __init__( client_info: types.Implementation | None = None, *, sampling_capabilities: types.SamplingCapability | None = None, + extensions: dict[str, dict[str, Any]] | None = None, dispatcher: Dispatcher[Any] | None = None, ) -> None: self._session_read_timeout_seconds = read_timeout_seconds self._client_info = client_info or DEFAULT_CLIENT_INFO self._sampling_callback = sampling_callback or _default_sampling_callback self._sampling_capabilities = sampling_capabilities + self._extensions = extensions self._elicitation_callback = elicitation_callback or _default_elicitation_callback self._list_roots_callback = list_roots_callback or _default_list_roots_callback self._logging_callback = logging_callback or _default_logging_callback @@ -369,7 +371,9 @@ def _build_capabilities(self) -> types.ClientCapabilities: if self._list_roots_callback is not _default_list_roots_callback else None ) - return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots) + return types.ClientCapabilities( + sampling=sampling, elicitation=elicitation, experimental=None, extensions=self._extensions, roots=roots + ) async def initialize(self) -> types.InitializeResult: if self._initialize_result is not None: diff --git a/src/mcp/server/apps.py b/src/mcp/server/apps.py new file mode 100644 index 000000000..d5b9d9ed8 --- /dev/null +++ b/src/mcp/server/apps.py @@ -0,0 +1,242 @@ +"""MCP Apps extension (`io.modelcontextprotocol/ui`). + +MCP Apps lets a tool carry a reference to an interactive UI: the tool's +`_meta.ui.resourceUri` points at a `ui://` resource (an HTML document served +with the `text/html;profile=mcp-app` MIME type) that the host renders in a +sandboxed iframe. See https://modelcontextprotocol.io/specification/draft/extensions/apps +and the ext-apps spec for the wire format, and SEP-2133 for the extension framework. + +This is a self-contained, additive `Extension`: it contributes tools and +resources and advertises the capability, but does not intercept any core method. +A server opts in by passing an `Apps` instance to `MCPServer(extensions=[...])`. + + apps = Apps() + + @apps.tool(resource_uri="ui://clock/app.html", description="Current time") + def get_time(ctx: Context) -> str: + return datetime.now(timezone.utc).isoformat() + + apps.add_html_resource("ui://clock/app.html", CLOCK_HTML) + + mcp = MCPServer("clock", extensions=[apps]) + +Per SEP-2133, an extension MUST degrade gracefully: a UI-enabled tool should +still return meaningful text for clients that did not negotiate Apps. Use +`client_supports_apps(ctx)` to branch on the client's advertised support. (The SDK +keeps Apps in-core under `mcp.server.apps` rather than a separate package; the +TypeScript and C# SDKs ship it as a standalone package.) +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any, Literal, TypeVar + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, ResourceBinding, ToolBinding +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.resources import Resource, TextResource + +EXTENSION_ID = "io.modelcontextprotocol/ui" +"""The MCP Apps extension identifier (the shipped TS/C# constant).""" + +APP_MIME_TYPE = "text/html;profile=mcp-app" +"""MIME type for a `ui://` app resource.""" + +Visibility = Literal["model", "app"] +"""Where a UI-bound tool is surfaced (`_meta.ui.visibility`).""" + +_CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) + + +class ResourcePermissions(BaseModel): + """Iframe permissions a `ui://` resource requests (`_meta.ui.permissions`).""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + camera: dict[str, Any] | None = None + microphone: dict[str, Any] | None = None + geolocation: dict[str, Any] | None = None + clipboard_write: dict[str, Any] | None = None + + +class ResourceCsp(BaseModel): + """Content-Security-Policy domains for a `ui://` resource (`_meta.ui.csp`).""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + connect_domains: list[str] | None = None + resource_domains: list[str] | None = None + frame_domains: list[str] | None = None + base_uri_domains: list[str] | None = None + + +class Apps(Extension): + """The MCP Apps extension: bind tools to `ui://` UI resources. + + Register UI-bound tools with `@apps.tool(resource_uri=...)` and their HTML + with `add_html_resource(...)`, then pass the instance to + `MCPServer(extensions=[apps])`. + """ + + identifier = EXTENSION_ID + + def __init__(self) -> None: + self._tools: list[tuple[ToolBinding, str]] = [] # (binding, bound resource_uri) + self._resources: list[ResourceBinding] = [] + + def tool( + self, + *, + resource_uri: str, + visibility: Sequence[Visibility] | None = None, + meta: dict[str, Any] | None = None, + **tool_kwargs: Any, + ) -> Callable[[_CallableT], _CallableT]: + """Decorator registering a tool bound to a `ui://` resource. + + Stamps `_meta.ui.resourceUri` (and `_meta.ui.visibility` when given) on the + tool. `tool_kwargs` are forwarded to `MCPServer.add_tool` (name, title, + description, annotations, ...); pass `meta=` to merge extra `_meta` keys + alongside the `ui` entry. + + Args: + resource_uri: The `ui://` URI of the UI resource this tool renders. + visibility: Where the tool is surfaced (`["model", "app"]`). + meta: Additional `_meta` keys to merge with the `ui` entry. + + Raises: + ValueError: If `resource_uri` does not use the `ui://` scheme, or + `meta` carries a `"ui"` key (the decorator owns `_meta["ui"]`). + """ + _require_ui_scheme(resource_uri) + if meta and "ui" in meta: + raise ValueError("Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key") + ui: dict[str, Any] = {"resourceUri": resource_uri} + if visibility is not None: + ui["visibility"] = list(visibility) + + def decorator(fn: _CallableT) -> _CallableT: + binding = ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs) + self._tools.append((binding, resource_uri)) + return fn + + return decorator + + def add_html_resource( + self, + uri: str, + html: str, + *, + name: str | None = None, + title: str | None = None, + description: str | None = None, + csp: ResourceCsp | None = None, + permissions: ResourcePermissions | None = None, + domain: str | None = None, + prefers_border: bool | None = None, + ) -> None: + """Register a `ui://` HTML resource served as `text/html;profile=mcp-app`. + + `csp`, `permissions`, `domain`, and `prefers_border` populate the + resource's `_meta.ui` per the ext-apps spec. + + Args: + uri: The `ui://` URI; a tool references it via `resource_uri`. + html: The HTML document the host renders. + + Raises: + ValueError: If `uri` does not use the `ui://` scheme. + """ + ui: dict[str, Any] = {} + if csp is not None: + ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True) + if permissions is not None: + ui["permissions"] = permissions.model_dump(by_alias=True, exclude_none=True) + if domain is not None: + ui["domain"] = domain + if prefers_border is not None: + ui["prefersBorder"] = prefers_border + self.add_resource( + TextResource( + uri=uri, + name=name or uri, + title=title, + description=description, + mime_type=APP_MIME_TYPE, + meta={"ui": ui} if ui else None, + text=html, + ) + ) + + def add_resource(self, resource: Resource) -> None: + """Register a pre-built `ui://` resource. + + The escape hatch for resources `add_html_resource` cannot express (e.g. a + `FileResource` serving HTML from disk). A resource without an explicit + `mime_type` is served as `text/html;profile=mcp-app` — hosts will not + render a `ui://` resource under any other MIME type, so an explicit + mismatch is rejected. + + Raises: + ValueError: If the resource URI does not use the `ui://` scheme, or + its explicit `mime_type` is not `text/html;profile=mcp-app`. + """ + _require_ui_scheme(resource.uri) + if "mime_type" not in resource.model_fields_set: + resource = resource.model_copy(update={"mime_type": APP_MIME_TYPE}) + elif resource.mime_type != APP_MIME_TYPE: + raise ValueError(f"MCP Apps resources are served as {APP_MIME_TYPE!r}, got {resource.mime_type!r}") + self._resources.append(ResourceBinding(resource=resource)) + + def tools(self) -> Sequence[ToolBinding]: + """The bound tools. + + Raises: + ValueError: If a tool's `resource_uri` has no matching resource + registered on this instance — a tool advertising a + `_meta.ui.resourceUri` that 404s on `resources/read` is a + misconfiguration, caught when the server consumes the extension. + """ + registered = {binding.resource.uri for binding in self._resources} + for tool, uri in self._tools: + if uri not in registered: + raise ValueError( + f"Apps tool {tool.fn.__name__!r} binds resource_uri {uri!r}, but no such resource " + "is registered; add it with add_html_resource() or add_resource()" + ) + return [tool for tool, _ in self._tools] + + def resources(self) -> Sequence[ResourceBinding]: + return self._resources + + +def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> bool: + """Whether the connected client negotiated MCP Apps support. + + Returns `True` only when the client advertised the extension AND listed the + `text/html;profile=mcp-app` MIME type in its settings, so a UI-enabled tool + can fall back to text-only output otherwise. + """ + capabilities = _client_capabilities(ctx) + extensions = capabilities.extensions if capabilities else None + settings = extensions.get(EXTENSION_ID) if extensions else None + if settings is None: + return False + mime_types = settings.get("mimeTypes") + return isinstance(mime_types, list | tuple) and APP_MIME_TYPE in mime_types + + +def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any: + if isinstance(ctx, Context): + return ctx.client_capabilities + client_params = ctx.session.client_params + return client_params.capabilities if client_params else None + + +def _require_ui_scheme(uri: str) -> None: + if not uri.startswith("ui://"): + raise ValueError(f"MCP Apps URIs must use the ui:// scheme, got {uri!r}") diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 76917f896..4d9496fef 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -345,4 +345,14 @@ def check_capability(self, capability: ClientCapabilities) -> bool: for k, v in capability.experimental.items(): if k not in have.experimental or have.experimental[k] != v: return False + if capability.extensions is not None: + # SEP-2133: an extension is supported when the client declares its + # identifier. Settings are negotiated per-extension (the client may + # advertise more than the server asks for), so presence - not value + # equality - is the meaningful check. + if have.extensions is None: + return False + for identifier in capability.extensions: + if identifier not in have.extensions: + return False return True diff --git a/src/mcp/server/extension.py b/src/mcp/server/extension.py new file mode 100644 index 000000000..e045e6f29 --- /dev/null +++ b/src/mcp/server/extension.py @@ -0,0 +1,195 @@ +"""Pluggable extension interface for MCP servers (SEP-2133). + +An extension is a self-contained, opt-in bundle of MCP behaviour, identified by +a reverse-DNS string (e.g. `io.modelcontextprotocol/ui`). It is passed to +`MCPServer(extensions=[...])`, and the server applies a *closed* set of +contribution kinds: tools, resources, new request methods, and one `tools/call` +interceptor. The server never hands itself to an extension; the extension +declares what it adds, and the server consumes it. + +The shape follows the HTTPX `Transport`/`Auth` pattern: a narrow base class whose +methods have sensible defaults, so an extension overrides only what it needs. A +purely additive extension (Apps) overrides `tools`/`resources`; an interceptive +one overrides `methods`/`intercept_tool_call`. + +This module lives at the `mcp.server` tier (not `mcp.server.mcpserver`) so the +base class itself never drags in the composition tier that consumes it; +extensions remain importable without constructing an `MCPServer`. +""" + +from __future__ import annotations + +import re +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from mcp_types import CallToolRequestParams +from mcp_types.methods import SPEC_CLIENT_METHODS +from pydantic import BaseModel + +from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext + +if TYPE_CHECKING: + from mcp.server.mcpserver.resources import Resource + +RequestHandler = Callable[[ServerRequestContext[Any, Any], Any], Awaitable[HandlerResult]] + +# Extension identifiers follow the `_meta` key grammar with a mandatory prefix +# (SEP-2133 / basic/index.mdx): dot-separated labels, each starting with a +# letter and ending with a letter or digit (hyphens interior), then `/`, then a +# name that starts and ends alphanumeric (`.`/`_`/`-` interior). +_LABEL = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?" +_NAME = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?" +_IDENTIFIER_RE = re.compile(rf"{_LABEL}(?:\.{_LABEL})*/{_NAME}") + + +def validate_extension_identifier(identifier: Any, *, owner: str) -> None: + """Raise `TypeError` unless `identifier` is a `vendor-prefix/name` string. + + SEP-2133 requires extension identifiers to carry a reverse-DNS prefix. + """ + if not isinstance(identifier, str) or not _IDENTIFIER_RE.fullmatch(identifier): + raise TypeError( + f"{owner}.identifier must be a `vendor-prefix/name` string " + f"(reverse-DNS prefix required), got {identifier!r}" + ) + + +@dataclass(frozen=True) +class ToolBinding: + """A tool an extension contributes, plus the `_meta` to stamp on it.""" + + fn: Callable[..., Any] + meta: dict[str, Any] | None = None + kwargs: dict[str, Any] = field(default_factory=lambda: {}) + + +@dataclass(frozen=True) +class ResourceBinding: + """A pre-built resource an extension contributes.""" + + resource: Resource + + +@dataclass(frozen=True) +class MethodBinding: + """A new request method an extension serves, e.g. `tasks/get`. + + `params_type` validates incoming params before `handler` runs; it should + subclass `RequestParams` so `_meta` parses uniformly. `protocol_versions`, + when set, restricts the method to those wire versions - a request for the + method at any other version is rejected as `METHOD_NOT_FOUND`, mirroring the + spec's `(method, version)` boundary table. `None` (the default) admits the + method at every version. + + Extension methods are additive: `method` must not name a spec-defined + request method (`tools/list`, `completion/complete`, ...) — those handlers + belong to the server, and an extension binding one would silently shadow or + be shadowed by it. Both constraints are enforced at construction. To + re-provide a spec method the 2026 revision removed (e.g. `logging/setLevel` + for legacy clients), use the lowlevel `Server.add_request_handler` API + instead — the runner's per-version surface gate would never route such a + method to an extension handler anyway. + """ + + method: str + params_type: type[BaseModel] + handler: RequestHandler + protocol_versions: frozenset[str] | None = None + + def __post_init__(self) -> None: + if self.method in SPEC_CLIENT_METHODS: + raise ValueError( + f"MethodBinding cannot bind spec method {self.method!r}; extension methods are " + "additive — use Extension.intercept_tool_call or Server.middleware to wrap core behaviour" + ) + if self.protocol_versions is not None and not self.protocol_versions: + raise ValueError( + f"MethodBinding for {self.method!r} has an empty protocol_versions set, so it could " + "never be served; use None to admit every version" + ) + + +class Extension: + """Base class for an opt-in MCP extension. Override only the methods you need. + + Subclass and set `identifier`, then override the contribution methods that + apply. Every method has a default, so a minimal extension overrides nothing + but `identifier` and one of `tools`/`resources`/`methods`. `identifier` is + enforced at subclass-definition time. + """ + + #: Reverse-DNS extension identifier, advertised under `ServerCapabilities.extensions`. + identifier: str + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + # Validate a class-level `identifier` at definition time. A subclass may + # instead assign `identifier` in `__init__` (per-instance ids); that case + # is validated when the extension is applied, since no class attribute + # exists to inspect here. + identifier = cls.__dict__.get("identifier") + if identifier is not None: + validate_extension_identifier(identifier, owner=cls.__name__) + + def settings(self) -> dict[str, Any]: + """Per-extension settings advertised at `capabilities.extensions[identifier]`. + + An empty dict (the default) advertises the extension with no settings. + """ + return {} + + def tools(self) -> Sequence[ToolBinding]: + """Tools this extension contributes (additive).""" + return () + + def resources(self) -> Sequence[ResourceBinding]: + """Resources this extension contributes (additive).""" + return () + + def methods(self) -> Sequence[MethodBinding]: + """New request methods this extension serves (additive).""" + return () + + async def intercept_tool_call( + self, + params: CallToolRequestParams, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + """Wrap `tools/call`. Default: pass through unchanged. + + Override to short-circuit (return a result without calling `call_next`) + or to observe the call. `params` is the validated `tools/call` params; + `call_next(ctx)` runs the rest of the chain and the real handler. + """ + return await call_next(ctx) + + +def compose_tool_call_interceptor(extensions: Sequence[Extension]) -> ServerMiddleware[Any]: + """Fold every extension's `intercept_tool_call` into one `ServerMiddleware`. + + The returned middleware nests the interceptors (first extension outermost) + and is a no-op for any method other than `tools/call`. It validates the + `tools/call` params once and threads them to each interceptor. + """ + + async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + if ctx.method != "tools/call": + return await call_next(ctx) + params = CallToolRequestParams.model_validate({} if ctx.params is None else ctx.params, by_name=False) + + chain = call_next + for extension in reversed(extensions): + chain = _bind_interceptor(extension, params, chain) + return await chain(ctx) + + return middleware + + +def _bind_interceptor(extension: Extension, params: CallToolRequestParams, call_next: CallNext) -> CallNext: + async def call(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return await extension.intercept_tool_call(params, ctx, call_next) + + return call diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index bbd2ff331..76a66b352 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -434,6 +434,10 @@ def __init__( # Context/middleware rework (covariant `Context[L]`, outbound seam) before # v2 final. self.middleware: list[ServerMiddleware[LifespanResultT]] = [OpenTelemetryMiddleware()] + # SEP-2133 extension settings advertised under `ServerCapabilities.extensions` + # (identifier -> settings). Higher layers (e.g. `MCPServer(extensions=...)`) + # populate it; `get_capabilities` reads it when no explicit map is passed. + self.extensions: dict[str, dict[str, Any]] = {} logger.debug("Initializing server %r", name) _spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [ @@ -521,8 +525,15 @@ def create_initialization_options( self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, + extensions: dict[str, dict[str, Any]] | None = None, ) -> InitializationOptions: - """Create initialization options from this server instance.""" + """Create initialization options from this server instance. + + `extensions` advertises SEP-2133 extension support under + `ServerCapabilities.extensions`; keys are extension identifiers (e.g. + `io.modelcontextprotocol/ui`), values are per-extension settings. + Defaults to `self.extensions`, which higher layers populate. + """ return InitializationOptions( server_name=self.name, server_version=self.version if self.version else _package_version("mcp"), @@ -531,6 +542,7 @@ def create_initialization_options( capabilities=self.get_capabilities( notification_options or NotificationOptions(), experimental_capabilities or {}, + extensions if extensions is not None else self.extensions, ), instructions=self.instructions, website_url=self.website_url, @@ -541,8 +553,14 @@ def get_capabilities( self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, + extensions: dict[str, dict[str, Any]] | None = None, ) -> types.ServerCapabilities: - """Convert existing handlers to a ServerCapabilities object.""" + """Convert existing handlers to a ServerCapabilities object. + + `extensions` is the SEP-2133 extension map (identifier -> settings) + advertised under `ServerCapabilities.extensions`; it defaults to + `self.extensions`. + """ notification_options = notification_options or NotificationOptions() prompts_capability = None resources_capability = None @@ -579,6 +597,7 @@ def get_capabilities( tools=tools_capability, logging=logging_capability, experimental=experimental_capabilities, + extensions=extensions if extensions is not None else (self.extensions or None), completions=completions_capability, ) return capabilities diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index 741f16beb..7a8da42fe 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -2,9 +2,11 @@ from mcp_types import Icon +from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding + from .context import Context from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity -from .server import MCPServer +from .server import MCPServer, require_client_extension from .utilities.types import Audio, Image __all__ = [ @@ -13,6 +15,11 @@ "Image", "Audio", "Icon", + "Extension", + "ToolBinding", + "ResourceBinding", + "MethodBinding", + "require_client_extension", "ResourceSecurity", "DEFAULT_RESOURCE_SECURITY", ] diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index a25213e7b..e295e21e0 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -26,7 +26,7 @@ class TextResource(Resource): async def read(self) -> str: """Read the text content.""" - return self.text # pragma: no cover + return self.text class BinaryResource(Resource): diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 029512a78..33348c083 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -4,7 +4,7 @@ import base64 import inspect -from collections.abc import AsyncIterator, Awaitable, Callable, Iterable +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import Any, Generic, Literal, TypeVar, overload @@ -13,10 +13,13 @@ from mcp_types import ( INTERNAL_ERROR, INVALID_PARAMS, + METHOD_NOT_FOUND, + MISSING_REQUIRED_CLIENT_CAPABILITY, Annotations, BlobResourceContents, CallToolRequestParams, CallToolResult, + ClientCapabilities, CompleteRequestParams, CompleteResult, Completion, @@ -28,6 +31,7 @@ ListResourcesResult, ListResourceTemplatesResult, ListToolsResult, + MissingRequiredClientCapabilityErrorData, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, @@ -54,7 +58,14 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier from mcp.server.auth.settings import AuthSettings -from mcp.server.context import ServerRequestContext +from mcp.server.context import HandlerResult, ServerRequestContext +from mcp.server.extension import ( + Extension, + MethodBinding, + RequestHandler, + compose_tool_call_interceptor, + validate_extension_identifier, +) from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel.server import LifespanResultT, Server from mcp.server.lowlevel.server import lifespan as default_lifespan @@ -148,6 +159,7 @@ def __init__( *, tools: list[Tool] | None = None, resources: list[Resource] | None = None, + extensions: Sequence[Extension] | None = None, debug: bool = False, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", warn_on_duplicate_resources: bool = True, @@ -215,6 +227,11 @@ def __init__( # Configure logging configure_logging(self.settings.log_level) + self._extensions: list[Extension] = [] + for extension in extensions or (): + self._apply_extension(extension) + self._install_extension_interceptor() + @property def name(self) -> str: return self._lowlevel_server.name @@ -255,6 +272,44 @@ def session_manager(self) -> StreamableHTTPSessionManager: """ return self._lowlevel_server.session_manager + def _apply_extension(self, extension: Extension) -> None: + """Apply one opt-in extension's contributions through the public surface. + + Registers its tools/resources/methods and advertises its settings under + `ServerCapabilities.extensions[extension.identifier]`. Extensions are fixed + at construction, so this is private; the `tools/call` interceptor is + composed once afterwards by `_install_extension_interceptor`. + """ + identifier = getattr(extension, "identifier", None) + validate_extension_identifier(identifier, owner=type(extension).__name__) + if any(e.identifier == identifier for e in self._extensions): + raise ValueError(f"Extension {identifier!r} is already registered") + self._extensions.append(extension) + + for tool in extension.tools(): + self.add_tool(tool.fn, meta=tool.meta, **tool.kwargs) + for resource in extension.resources(): + self.add_resource(resource.resource) + for method in extension.methods(): + if self._lowlevel_server.get_request_handler(method.method) is not None: + raise ValueError( + f"Extension {identifier!r} binds method {method.method!r}, which is already " + "registered; extension methods are additive and cannot replace another handler" + ) + handler = _version_gated(method) if method.protocol_versions is not None else method.handler + self._lowlevel_server.add_request_handler(method.method, method.params_type, handler) + + self._lowlevel_server.extensions[extension.identifier] = extension.settings() + + def _install_extension_interceptor(self) -> None: + """Compose every extension's `tools/call` interceptor into one middleware. + + Installed only when at least one extension overrides `intercept_tool_call`, + so a server with purely additive extensions adds no middleware. + """ + if any(type(e).intercept_tool_call is not Extension.intercept_tool_call for e in self._extensions): + self._lowlevel_server.middleware.append(compose_tool_call_interceptor(self._extensions)) + @overload def run(self, transport: Literal["stdio"] = ...) -> None: ... @@ -1152,3 +1207,50 @@ async def get_prompt( except Exception as e: logger.exception(f"Error getting prompt {name}") raise ValueError(str(e)) from e + + +def _version_gated(method: MethodBinding) -> RequestHandler: + """Wrap a method handler so a request at a disallowed protocol version is rejected. + + The low-level `_request_handlers` dict is keyed by method only, so per-version + scoping is enforced here rather than at the runner's boundary table. + """ + versions = method.protocol_versions + assert versions is not None + + async def gated(ctx: ServerRequestContext[Any, Any], params: Any) -> HandlerResult: + if ctx.protocol_version not in versions: + raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method.method) + return await method.handler(ctx, params) + + return gated + + +def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: str) -> None: + """Assert the connected client declared support for `identifier`. + + Call this from an extension's handler or `intercept_tool_call` before + offering extension-specific behaviour. Raises `MCPError` with the + `-32021` (missing required client capability) code and a + `requiredCapabilities` payload when the client did not declare the + extension, per SEP-2133. + + Args: + ctx: The current request context. + identifier: The extension identifier the client must have declared. + + Raises: + MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` if the client + did not advertise `identifier`. + """ + client_params = ctx.session.client_params + declared = client_params.capabilities.extensions if client_params else None + if not declared or identifier not in declared: + data = MissingRequiredClientCapabilityErrorData( + required_capabilities=ClientCapabilities(extensions={identifier: {}}) + ) + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message=f"Client did not declare required extension {identifier!r}", + data=data.model_dump(by_alias=True, mode="json", exclude_none=True), + ) diff --git a/tests/docs_src/test_apps.py b/tests/docs_src/test_apps.py new file mode 100644 index 000000000..02375f97a --- /dev/null +++ b/tests/docs_src/test_apps.py @@ -0,0 +1,100 @@ +"""`docs/advanced/apps.md`: every claim the page makes, proved against the real SDK.""" + +from typing import Any + +import pytest +from mcp_types import TextContent, TextResourceContents + +from docs_src.apps import tutorial001, tutorial002, tutorial003 +from mcp import Client +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_tool_carries_the_ui_resource_reference() -> None: + """tutorial001: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the tool.""" + async with Client(tutorial001.mcp) as client: + listed = await client.list_tools() + assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://clock/app.html"}} + + +async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None: + """tutorial001: `add_html_resource` serves the HTML at `text/html;profile=mcp-app`, + the MIME type that tells a host "this is an app, render it".""" + async with Client(tutorial001.mcp) as client: + result = await client.read_resource("ui://clock/app.html") + contents = result.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.mime_type == APP_MIME_TYPE + assert contents.text == tutorial001.CLOCK_HTML + + +async def test_one_tool_two_answers() -> None: + """tutorial001: the canonical degradation pattern: raw data for a client that + negotiated Apps, a human sentence for one that did not.""" + async with Client(tutorial001.mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as ui_client: + rich = await ui_client.call_tool("get_time", {}) + async with Client(tutorial001.mcp) as text_client: + plain = await text_client.call_tool("get_time", {}) + assert rich.content == [TextContent(type="text", text="2026-06-26T12:00:00Z")] + assert plain.content == [TextContent(type="text", text="The time is 2026-06-26T12:00:00Z.")] + + +async def test_the_clock_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial001: `main()` declares Apps support with the required `mimeTypes` and + receives the rich answer the page promises.""" + await tutorial001.main() + assert "2026-06-26T12:00:00Z" in capsys.readouterr().out + + +async def test_capability_advertised_under_server_extensions() -> None: + """tutorial001: passing `extensions=[apps]` advertises `io.modelcontextprotocol/ui`.""" + async with Client(tutorial001.mcp) as client: + assert client.server_capabilities.extensions == {EXTENSION_ID: {}} + + +async def test_csp_permissions_domain_and_border_ride_the_resource_meta() -> None: + """tutorial002: the iframe lockdown fields land under `_meta.ui` on both the list + entry and the read content item, with the spec's camelCase wire keys.""" + expected: dict[str, Any] = { + "ui": { + "csp": {"connectDomains": ["https://api.example.com"]}, + "permissions": {"clipboardWrite": {}}, + "domain": "dashboard.example.com", + "prefersBorder": True, + } + } + async with Client(tutorial002.mcp) as client: + listed = await client.list_resources() + result = await client.read_resource("ui://dashboard/app.html") + assert listed.resources[0].meta == expected + contents = result.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.meta == expected + + +async def test_an_app_only_tool_is_still_listed_and_callable() -> None: + """tutorial002: `visibility=["app"]` is metadata for the host; the server lists the + tool like any other and serves its calls. Filtering is the host's job.""" + async with Client(tutorial002.mcp) as client: + listed = await client.list_tools() + result = await client.call_tool("refresh_dashboard", {}) + assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://dashboard/app.html", "visibility": ["app"]}} + assert result.content == [TextContent(type="text", text="refreshed")] + + +async def test_a_file_resource_is_served_with_the_app_mime_type_filled_in() -> None: + """tutorial003: `add_resource` accepts a pre-built `FileResource` and fills in the + `text/html;profile=mcp-app` MIME type the resource didn't set explicitly.""" + async with Client(tutorial003.mcp) as client: + listed = await client.list_tools() + called = await client.call_tool("refresh_report", {}) + result = await client.read_resource("ui://report/app.html") + assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://report/app.html"}} + assert called.content == [TextContent(type="text", text="report refreshed")] + contents = result.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.mime_type == APP_MIME_TYPE + assert contents.text == tutorial003.REPORT_HTML.read_text() diff --git a/tests/docs_src/test_extensions.py b/tests/docs_src/test_extensions.py new file mode 100644 index 000000000..ebe00e5a8 --- /dev/null +++ b/tests/docs_src/test_extensions.py @@ -0,0 +1,97 @@ +"""`docs/advanced/extensions.md`: every claim the page makes, proved against the real SDK.""" + +import logging +from typing import cast + +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent + +from docs_src.extensions import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005 +from mcp import Client, MCPError +from mcp.server.extension import Extension + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_using_an_extension_advertises_its_capability() -> None: + """tutorial001: `extensions=[Apps()]` is all it takes for the server to advertise + the extension under `capabilities.extensions`.""" + async with Client(tutorial001.mcp) as client: + assert client.server_capabilities.extensions == {"io.modelcontextprotocol/ui": {}} + + +def test_a_prefixless_identifier_fails_at_class_definition() -> None: + """tutorial002 + the page's TypeError block: the identifier is validated when the + subclass is defined, with the exact message the page shows.""" + assert tutorial002.Stamps.identifier == "com.example/stamps" + with pytest.raises(TypeError) as exc_info: + type("Stamps", (Extension,), {"identifier": "stamps"}) + assert str(exc_info.value) == snapshot( + "Stamps.identifier must be a `vendor-prefix/name` string (reverse-DNS prefix required), got 'stamps'" + ) + + +async def test_extension_settings_advertised_under_capabilities() -> None: + """tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`.""" + async with Client(tutorial003.mcp) as client: + assert client.server_capabilities.extensions == {"com.example/stamps": {"sealed": True}} + + +async def test_contributed_tool_is_listed_and_callable() -> None: + """tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable.""" + async with Client(tutorial003.mcp) as client: + listed = await client.list_tools() + assert [tool.name for tool in listed.tools] == ["stamp"] + result = await client.call_tool("stamp", {"text": "hello"}) + assert result.content == [TextContent(type="text", text="[stamped] hello")] + + +async def test_the_stamps_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial003: `main()` is the literal client program on the page; both printed + lines match the page's comments.""" + await tutorial003.main() + out = capsys.readouterr().out + assert "{'com.example/stamps': {'sealed': True}}" in out + assert "[stamped] hello" in out + + +async def test_the_search_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial004: `main()` declares the extension and gets the vendor method's result.""" + await tutorial004.main() + assert "['mcp-0', 'mcp-1', 'mcp-2']" in capsys.readouterr().out + + +async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None: + """tutorial004: `require_client_extension` answers a non-declaring client with `-32021` + and the machine-readable `requiredCapabilities` payload.""" + async with Client(tutorial004.mcp) as client: + request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) + with pytest.raises(MCPError) as exc_info: + await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult) + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}} + + +async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None: + """tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND + at any other wire version; for a legacy client it doesn't exist.""" + async with Client(tutorial004.mcp, mode="legacy", extensions={tutorial004.EXTENSION_ID: {}}) as client: + request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) + with pytest.raises(MCPError) as exc_info: + await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult) + assert exc_info.value.code == METHOD_NOT_FOUND + + +async def test_interceptor_observes_the_call_and_passes_the_result_through( + caplog: pytest.LogCaptureFixture, +) -> None: + """tutorial005: the interceptor logs the tool name and returns `call_next`'s result unchanged.""" + with caplog.at_level(logging.INFO, logger=tutorial005.logger.name): + async with Client(tutorial005.mcp) as client: + result = await client.call_tool("add", {"a": 2, "b": 3}) + assert result.structured_content == {"result": 5} + messages = [record.getMessage() for record in caplog.records if record.name == tutorial005.logger.name] + assert messages == ["tool 'add' called"] diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py new file mode 100644 index 000000000..e2ec366b2 --- /dev/null +++ b/tests/server/mcpserver/test_extension.py @@ -0,0 +1,485 @@ +"""Tests for the core SEP-2133 extension API (`Extension`, `MCPServer` wiring). + +These exercise the closed set of extension contribution kinds - tools, +resources, request methods, and the single `tools/call` interceptor - through +the highest-level public surface (in-memory `Client`), plus the +`compose_tool_call_interceptor` helper directly. +""" + +from typing import Any, Literal, cast + +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + METHOD_NOT_FOUND, + MISSING_REQUIRED_CLIENT_CAPABILITY, + CallToolResult, + TextContent, +) + +from mcp.client.client import Client +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.extension import ( + Extension, + MethodBinding, + ResourceBinding, + ToolBinding, + compose_tool_call_interceptor, + validate_extension_identifier, +) +from mcp.server.mcpserver import Context, MCPServer, require_client_extension +from mcp.server.mcpserver.resources import TextResource +from mcp.shared.exceptions import MCPError + +pytestmark = pytest.mark.anyio + +_TOOL_META: dict[str, Any] = {"com.example/marker": {"v": 1}} + + +class _AdditiveExt(Extension): + """Override `tools()`/`resources()` only - a purely additive extension.""" + + identifier = "com.example/additive" + + def tools(self): + def ping() -> str: + """Reply with pong.""" + return "pong" + + return [ToolBinding(fn=ping, meta=_TOOL_META)] + + def resources(self): + return [ResourceBinding(resource=TextResource(uri="ext://greeting", name="greeting", text="hello"))] + + +class _SettingsExt(Extension): + """Override `settings()` so the extension advertises a non-empty settings map.""" + + identifier = "com.example/settings" + + def settings(self) -> dict[str, Any]: + return {"feature": {"enabled": True}} + + +class _PingParams(types.RequestParams): + pass + + +class _PingResult(types.Result): + pong: bool + + +class _PingRequest(types.Request[_PingParams, Literal["com.example/ping"]]): + method: Literal["com.example/ping"] = "com.example/ping" + params: _PingParams + + +async def _pong_handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult: + """The shared `com.example/ping` handler (dispatched by the reachability test).""" + return _PingResult(pong=True) + + +class _MethodExt(Extension): + """Override `methods()` to serve a new vendor request verb.""" + + identifier = "com.example/method" + + def methods(self) -> list[MethodBinding]: + return [MethodBinding("com.example/ping", _PingParams, _pong_handler)] + + +class _ReplacingExt(Extension): + """Override `intercept_tool_call()` to short-circuit with a fixed result.""" + + identifier = "com.example/replacing" + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + return CallToolResult(content=[TextContent(type="text", text="intercepted")]) + + +class _PassThroughExt(Extension): + """Override `intercept_tool_call()` but always delegate to `call_next` unchanged.""" + + identifier = "com.example/passthrough" + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + return await call_next(ctx) + + +class _DefaultExt(Extension): + """Override nothing - relies on the base `intercept_tool_call` default (pass through).""" + + identifier = "com.example/default" + + +class _RecordingExt(Extension): + """Override `intercept_tool_call()` to record `(identifier, tool_name)` then pass through.""" + + def __init__(self, identifier: str, log: list[tuple[str, str]]) -> None: + self.identifier = identifier + self._log = log + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + self._log.append((self.identifier, params.name)) + return await call_next(ctx) + + +def _echo(value: str) -> str: + """Echo the input value (shared tool body across interceptor tests).""" + return value + + +async def test_additive_extension_registers_its_tool_and_resource() -> None: + """SDK-defined: an `Extension` overriding `tools()`/`resources()` surfaces both + through `MCPServer`'s normal `list_tools`/`list_resources`, and the tool's + `_meta` round-trips equal to the exact dict the binding carried (identity can't + hold - the value is JSON-serialized over the transport).""" + server = MCPServer("test", extensions=[_AdditiveExt()]) + + async with Client(server) as client: + tools = await client.list_tools() + resources = await client.list_resources() + called = await client.call_tool("ping", {}) + + assert [t.name for t in tools.tools] == ["ping"] + assert tools.tools[0].meta == _TOOL_META + assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"})) + assert resources == snapshot( + types.ListResourcesResult( + resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")] + ) + ) + + +async def test_extension_settings_advertised_under_server_capabilities() -> None: + """SDK-defined: `settings()` rides `server/discover` and lands under + `server_capabilities.extensions[identifier]` on the modern (`auto`) path.""" + server = MCPServer("test", extensions=[_SettingsExt()]) + + async with Client(server, mode="auto") as client: + extensions = client.server_capabilities.extensions + + assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}}) + + +async def test_extension_settings_dropped_on_legacy_handshake() -> None: + """Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions` + field, so a legacy `initialize` handshake drops the advertised extension even + though the modern `auto` path carries it.""" + server = MCPServer("test", extensions=[_SettingsExt()]) + + async with Client(server, mode="legacy") as client: + assert client.server_capabilities.extensions is None + + +def test_duplicate_extension_identifier_raises() -> None: + """SDK-defined: registering two extensions with the same `identifier` is a + construction error.""" + with pytest.raises(ValueError): + MCPServer("test", extensions=[_SettingsExt(), _SettingsExt()]) + + +async def test_extension_method_reachable_via_session_send_request() -> None: + """SDK-defined: an `Extension` overriding `methods()` wires a new request verb + onto the low-level server, reachable through `client.session.send_request`.""" + server = MCPServer("test", extensions=[_MethodExt()]) + + async with Client(server) as client: + request = _PingRequest(params=_PingParams()) + result = await client.session.send_request(cast("types.ClientRequest", request), _PingResult) + + assert result == snapshot(_PingResult(pong=True)) + + +async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None: + """SDK-defined: an extension whose `intercept_tool_call` delegates to + `call_next` does not alter the underlying tool's `CallToolResult`.""" + server = MCPServer("test", extensions=[_PassThroughExt()]) + server.tool(name="echo")(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "hi"}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"})) + + +async def test_short_circuiting_interceptor_replaces_tool_result() -> None: + """SDK-defined: an extension that returns from `intercept_tool_call` without + calling `call_next` replaces the tool's result wholesale (the tool never runs).""" + server = MCPServer("test", extensions=[_ReplacingExt()]) + server.tool(name="echo", structured_output=False)(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "hi"}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")])) + + +def test_plain_extension_installs_no_tool_call_interceptor() -> None: + """SDK-defined: an extension that does not override `intercept_tool_call` adds no + middleware - the composed interceptor exists only when at least one extension + overrides it.""" + baseline = len(MCPServer("test")._lowlevel_server.middleware) + server = MCPServer("test", extensions=[_AdditiveExt()]) + + assert len(server._lowlevel_server.middleware) == baseline + + +def test_overriding_extension_installs_one_tool_call_interceptor() -> None: + """SDK-defined: an extension that overrides `intercept_tool_call` composes exactly + one additional `tools/call` middleware.""" + baseline = len(MCPServer("test")._lowlevel_server.middleware) + server = MCPServer("test", extensions=[_ReplacingExt()]) + + assert len(server._lowlevel_server.middleware) == baseline + 1 + + +async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None: + """SDK-defined: an extension that does not override `intercept_tool_call` runs the + base-class default (pass through) when another extension forces the composed + middleware to exist, leaving the tool result untouched.""" + server = MCPServer("test", extensions=[_DefaultExt(), _PassThroughExt()]) + server.tool(name="echo")(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "hi"}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"})) + + +async def test_interceptors_run_in_registration_order_with_threaded_params() -> None: + """SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so + two passing-through interceptors record in registration order, each seeing the + validated `tools/call` params (the real tool name).""" + log: list[tuple[str, str]] = [] + server = MCPServer( + "test", + extensions=[_RecordingExt("com.example/first", log), _RecordingExt("com.example/second", log)], + ) + server.tool(name="echo")(_echo) + + async with Client(server) as client: + await client.call_tool("echo", {"value": "hi"}) + + assert log == [("com.example/first", "echo"), ("com.example/second", "echo")] + + +async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None: + """SDK-defined: the composed middleware is a no-op for any method other than + `tools/call` - it forwards to `call_next` without touching the interceptors.""" + sentinel = types.EmptyResult() + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return sentinel + + middleware = compose_tool_call_interceptor([_ReplacingExt()]) + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tasks/get", + params={"taskId": "t-1"}, + ) + + result = await middleware(ctx, call_next) + + assert result is sentinel + + +def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None: + """SDK-defined: SEP-2133 requires a `vendor-prefix/name` identifier, enforced when the + subclass is defined (a bare name with no prefix is a TypeError).""" + with pytest.raises(TypeError): + type("_BadExt", (Extension,), {"identifier": "noprefix"}) + + +def test_extension_without_identifier_is_rejected_at_registration() -> None: + """SDK-defined: a subclass that never sets `identifier` (neither class-level nor in + `__init__`) is rejected when the server applies it.""" + + class _NoIdExt(Extension): + pass + + with pytest.raises(TypeError): + MCPServer("test", extensions=[_NoIdExt()]) + + +class _VersionPinnedParams(types.RequestParams): + pass + + +class _VersionPinnedResult(types.Result): + ok: bool + + +class _VersionPinnedRequest(types.Request[_VersionPinnedParams, Literal["com.example/pinned"]]): + method: Literal["com.example/pinned"] = "com.example/pinned" + params: _VersionPinnedParams + + +class _VersionPinnedExt(Extension): + """A method scoped to 2026-07-28 only via `MethodBinding.protocol_versions`.""" + + identifier = "com.example/pinned" + + def methods(self): + async def handler(ctx: ServerRequestContext[Any, Any], params: _VersionPinnedParams) -> _VersionPinnedResult: + return _VersionPinnedResult(ok=True) + + return [MethodBinding("com.example/pinned", _VersionPinnedParams, handler, frozenset({"2026-07-28"}))] + + +async def test_version_pinned_method_is_served_at_an_allowed_version() -> None: + """SDK-defined: a `MethodBinding` with `protocol_versions` serves the method at a version + in the set.""" + server = MCPServer("test", extensions=[_VersionPinnedExt()]) + + async with Client(server, mode="2026-07-28") as client: + request = _VersionPinnedRequest(params=_VersionPinnedParams()) + result = await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult) + + assert result == snapshot(_VersionPinnedResult(ok=True)) + + +async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None: + """SDK-defined: the same method at a version outside `protocol_versions` is rejected with + METHOD_NOT_FOUND, mirroring the spec's per-version boundary.""" + server = MCPServer("test", extensions=[_VersionPinnedExt()]) + + async with Client(server, mode="legacy") as client: + request = _VersionPinnedRequest(params=_VersionPinnedParams()) + with pytest.raises(MCPError) as exc_info: + await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult) + + assert exc_info.value.code == METHOD_NOT_FOUND + assert exc_info.value.error.data == "com.example/pinned" + + +@pytest.mark.parametrize( + "identifier", + [ + "io.modelcontextprotocol/ui", + "com.example/my_ext", + "com.x-y.z2/n.a-b_c", + "example/x", + "a/b", + "com.example/9start", + ], +) +def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None: + """Spec `_meta` key grammar: dot-separated labels (letter start, letter/digit end, + hyphens interior), a slash, then a name that starts and ends alphanumeric.""" + validate_extension_identifier(identifier, owner="T") + + +@pytest.mark.parametrize( + "identifier", + [ + "noprefix", + "-foo/bar", + ".leading/x", + "a..b/x", + "foo-/x", + "9foo/x", + "foo/-bar", + "foo/bar-", + "foo/", + "/bar", + "foo/ba r", + "io.modelcontextprotocol/ui\n", + "", + None, + 42, + ], +) +def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None: + """Spec `_meta` key grammar: malformed prefixes (bad label start/end, empty labels) + and malformed names are rejected, as are non-strings.""" + with pytest.raises(TypeError): + validate_extension_identifier(identifier, owner="T") + + +@pytest.mark.parametrize("method", ["tools/list", "completion/complete"]) +def test_method_binding_rejects_spec_methods(method: str) -> None: + """SDK-defined: extension methods are additive — binding a spec-defined request method + would silently shadow (or be shadowed by) the server's own handler, so it is rejected + when the binding is constructed.""" + with pytest.raises(ValueError): + MethodBinding(method, _PingParams, _pong_handler) + + +def test_method_binding_rejects_empty_protocol_versions() -> None: + """SDK-defined: an empty `protocol_versions` set would make the method unreachable at + every version; `None` is the universal-version spelling.""" + with pytest.raises(ValueError) as exc_info: + MethodBinding("com.example/dead", _PingParams, _pong_handler, frozenset()) + assert str(exc_info.value) == snapshot( + "MethodBinding for 'com.example/dead' has an empty protocol_versions set, so it could " + "never be served; use None to admit every version" + ) + + +class _OtherMethodExt(Extension): + """A second extension binding the same verb as `_MethodExt`.""" + + identifier = "com.example/other-method" + + def methods(self) -> list[MethodBinding]: + return [MethodBinding("com.example/ping", _PingParams, _pong_handler)] + + +def test_colliding_extension_methods_are_rejected_at_registration() -> None: + """SDK-defined: two extensions binding the same method would silently last-write-win; + the collision is rejected when the second extension is applied.""" + with pytest.raises(ValueError) as exc_info: + MCPServer("test", extensions=[_MethodExt(), _OtherMethodExt()]) + assert str(exc_info.value) == snapshot( + "Extension 'com.example/other-method' binds method 'com.example/ping', which is already " + "registered; extension methods are additive and cannot replace another handler" + ) + + +_NEEDS_EXT = "com.example/needed" + + +class _RequiresExt(Extension): + """A tool that requires the client to have declared `com.example/needed`.""" + + identifier = _NEEDS_EXT + + def tools(self): + def guarded(ctx: Context) -> str: + require_client_extension(ctx.request_context, _NEEDS_EXT) + return "ok" + + return [ToolBinding(fn=guarded)] + + +async def test_require_client_extension_passes_when_client_declared_it() -> None: + """SDK-defined: `require_client_extension` is a no-op when the client advertised the id.""" + server = MCPServer("test", extensions=[_RequiresExt()]) + + async with Client(server, extensions={_NEEDS_EXT: {}}) as client: + result = await client.call_tool("guarded", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"})) + + +async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None: + """SDK-defined: `require_client_extension` raises the -32021 missing-required-capability + error when the client did not advertise the id.""" + server = MCPServer("test", extensions=[_RequiresExt()]) + + async with Client(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("guarded", {}) + + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data == snapshot({"requiredCapabilities": {"extensions": {_NEEDS_EXT: {}}}}) diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py new file mode 100644 index 000000000..65908309a --- /dev/null +++ b/tests/server/test_apps.py @@ -0,0 +1,302 @@ +"""Tests for the MCP Apps extension (`io.modelcontextprotocol/ui`, SEP-2133). + +The headline property is SEP-2133 graceful degradation: a UI-bound tool returns +rich output to a client that negotiated Apps and text-only output to one that did +not. The remaining tests pin SDK-defined wiring (the `_meta.ui.resourceUri` stamp, +the `ui://` resource MIME type, capability advertisement, and `ui://`-scheme +validation). +""" + +from typing import Any + +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, ReadResourceResult, TextContent, TextResourceContents + +from mcp.client.client import Client +from mcp.server import Server, ServerRequestContext +from mcp.server.apps import ( + APP_MIME_TYPE, + EXTENSION_ID, + Apps, + ResourceCsp, + ResourcePermissions, + client_supports_apps, +) +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.resources import TextResource + +pytestmark = pytest.mark.anyio + + +def _clock_server() -> MCPServer: + apps = Apps() + + @apps.tool(resource_uri="ui://clock/app.html", title="Get Time", description="Return the current time.") + def get_time(ctx: Context) -> str: + if not client_supports_apps(ctx): + return "The time is 2026-06-26T00:00:00Z." + return "2026-06-26T00:00:00Z" + + apps.add_html_resource("ui://clock/app.html", "Clock", title="Clock") + return MCPServer("clock", extensions=[apps]) + + +async def test_apps_tool_stamps_ui_resource_uri_on_tool_meta() -> None: + """SDK-defined: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the + advertised tool, observed end-to-end through `list_tools`.""" + async with Client(_clock_server()) as client: + result = await client.list_tools() + assert [(t.name, t.meta) for t in result.tools] == snapshot( + [("get_time", {"ui": {"resourceUri": "ui://clock/app.html"}})] + ) + + +async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None: + """SDK-defined: `add_html_resource` registers the `ui://` resource served as + `text/html;profile=mcp-app`, observed through `read_resource`.""" + async with Client(_clock_server()) as client: + result = await client.read_resource("ui://clock/app.html") + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="ui://clock/app.html", + mime_type="text/html;profile=mcp-app", + text="Clock", + ) + ] + ) + ) + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].mime_type == APP_MIME_TYPE + + +async def test_auto_mode_carries_apps_extension_under_server_capabilities() -> None: + """SDK-defined: the Apps extension rides `server/discover`, so a `mode='auto'` client + sees `EXTENSION_ID` under `server_capabilities.extensions`.""" + async with Client(_clock_server(), mode="auto") as client: + assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/ui": {}}) + + +async def test_legacy_handshake_drops_apps_extension_from_capabilities() -> None: + """Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions` field, + so a `mode='legacy'` handshake cannot carry the Apps capability -- only `mode='auto'` + (server/discover) does. This pins the divergence rather than fixing it.""" + async with Client(_clock_server(), mode="legacy") as client: + assert client.server_capabilities.extensions is None + + +async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> None: + """SEP-2133 graceful degradation: a client that advertised `EXTENSION_ID` gets the + rich (UI) path, while one that did not gets the text-only fallback. The same tool, + branching on `client_supports_apps(ctx)`, drives both halves.""" + server = _clock_server() + + async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports: + rich = await supports.call_tool("get_time", {}) + async with Client(server) as plain: + fallback = await plain.call_tool("get_time", {}) + + assert rich.content == snapshot([TextContent(text="2026-06-26T00:00:00Z")]) + assert fallback.content == snapshot([TextContent(text="The time is 2026-06-26T00:00:00Z.")]) + + +async def _observed_client_supports_apps(extensions: dict[str, dict[str, Any]] | None) -> bool: + """Run one probe `tools/call` and report what `client_supports_apps` saw server-side. + + Exercises the lowlevel `ServerRequestContext` form, which reads the client's + advertised extensions off `session.client_params`. + """ + observed: list[bool] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "probe" + observed.append(client_supports_apps(ctx)) + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server("probe", on_list_tools=list_tools, on_call_tool=call_tool) + async with Client(server, extensions=extensions) as client: + await client.call_tool("probe", {}) + return observed[0] + + +@pytest.mark.parametrize( + ("extensions", "expected"), + [ + pytest.param({EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}, True, id="html-mime-listed"), + pytest.param({EXTENSION_ID: {"mimeTypes": (APP_MIME_TYPE,)}}, True, id="in-process-tuple-mime-types"), + pytest.param(None, False, id="extension-not-declared"), + pytest.param({EXTENSION_ID: {"mimeTypes": ["application/x-other"]}}, False, id="html-mime-not-offered"), + pytest.param({EXTENSION_ID: {}}, False, id="mime-types-key-missing"), + ], +) +async def test_client_supports_apps_from_lowlevel_request_context( + extensions: dict[str, dict[str, Any]] | None, expected: bool +) -> None: + """ext-apps: `client_supports_apps` is `True` only when the client declared the ui + extension AND listed `text/html;profile=mcp-app` in its `mimeTypes` settings — a + required field, so its absence means unsupported (the reference SDK's check is + `uiCap?.mimeTypes?.includes(...)`).""" + assert await _observed_client_supports_apps(extensions) is expected + + +def test_apps_tool_rejects_non_ui_resource_uri() -> None: + """SDK-defined: `@apps.tool` accepts only `ui://` URIs; any other scheme is a + programmer error raised at decoration time.""" + apps = Apps() + with pytest.raises(ValueError): + apps.tool(resource_uri="https://example.com/app.html") + + +def test_add_html_resource_rejects_non_ui_resource_uri() -> None: + """SDK-defined: `add_html_resource` accepts only `ui://` URIs; any other scheme is + a programmer error raised at registration time.""" + apps = Apps() + with pytest.raises(ValueError): + apps.add_html_resource("https://example.com/app.html", "x") + + +def _widget() -> str: + """A UI-bound tool body (shared so its one covered call serves both meta tests).""" + return "x" + + +async def test_apps_tool_stamps_visibility_when_given() -> None: + """SDK-defined: `@apps.tool(visibility=...)` is stamped into `_meta.ui.visibility`.""" + apps = Apps() + apps.tool(resource_uri="ui://v/app.html", visibility=["app"])(_widget) + apps.add_html_resource("ui://v/app.html", "v") + + async with Client(MCPServer("v", extensions=[apps])) as client: + result = await client.list_tools() + called = await client.call_tool("_widget", {}) + + assert result.tools[0].meta == snapshot({"ui": {"resourceUri": "ui://v/app.html", "visibility": ["app"]}}) + assert called.content == snapshot([TextContent(text="x")]) + + +async def test_apps_tool_merges_extra_meta_alongside_ui() -> None: + """SDK-defined: `@apps.tool(meta=...)` merges extra `_meta` keys with the `ui` entry + (previously a `meta=` argument raised a duplicate-keyword TypeError).""" + apps = Apps() + apps.tool(resource_uri="ui://m/app.html", meta={"com.example/k": 1})(_widget) + apps.add_html_resource("ui://m/app.html", "m") + + async with Client(MCPServer("m", extensions=[apps])) as client: + result = await client.list_tools() + + assert result.tools[0].meta == snapshot({"com.example/k": 1, "ui": {"resourceUri": "ui://m/app.html"}}) + + +async def test_add_html_resource_stamps_csp_and_permissions_on_resource_meta() -> None: + """SDK-defined: `csp`/`permissions` populate the resource's `_meta.ui` per ext-apps.""" + apps = Apps() + apps.add_html_resource( + "ui://r/app.html", + "r", + csp=ResourceCsp(connect_domains=["https://api.example.com"]), + permissions=ResourcePermissions(camera={}), + domain="r.example.com", + prefers_border=True, + ) + + async with Client(MCPServer("r", extensions=[apps])) as client: + listed = await client.list_resources() + result = await client.read_resource("ui://r/app.html") + + expected_ui_meta = snapshot( + { + "ui": { + "csp": {"connectDomains": ["https://api.example.com"]}, + "permissions": {"camera": {}}, + "domain": "r.example.com", + "prefersBorder": True, + } + } + ) + # Hosts read `_meta.ui` from the read content item, with the list entry as + # fallback — the SDK stamps the same value in both places. + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].meta == expected_ui_meta + assert listed.resources[0].meta == expected_ui_meta + + +def test_apps_tool_with_unregistered_resource_uri_is_rejected_at_construction() -> None: + """SDK-defined: a tool whose `resource_uri` has no matching registered resource would + advertise a `_meta.ui.resourceUri` that 404s on `resources/read`; the misconfiguration + is rejected when the server consumes the extension.""" + apps = Apps() + apps.tool(resource_uri="ui://missing/app.html")(_widget) + + with pytest.raises(ValueError) as exc_info: + MCPServer("broken", extensions=[apps]) + assert str(exc_info.value) == snapshot( + "Apps tool '_widget' binds resource_uri 'ui://missing/app.html', but no such resource " + "is registered; add it with add_html_resource() or add_resource()" + ) + + +async def test_add_resource_registers_a_prebuilt_ui_resource() -> None: + """SDK-defined: `add_resource` is the escape hatch for pre-built `ui://` resources + that `add_html_resource` cannot express; it satisfies a tool's `resource_uri` binding.""" + apps = Apps() + apps.tool(resource_uri="ui://prebuilt/app.html")(_widget) + apps.add_resource( + TextResource(uri="ui://prebuilt/app.html", name="prebuilt", mime_type=APP_MIME_TYPE, text="p") + ) + + async with Client(MCPServer("p", extensions=[apps])) as client: + result = await client.read_resource("ui://prebuilt/app.html") + + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "p" + + +def test_add_resource_rejects_non_ui_resource_uri() -> None: + """SDK-defined: `add_resource` accepts only `ui://` URIs, like the other registrars.""" + apps = Apps() + with pytest.raises(ValueError): + apps.add_resource(TextResource(uri="https://example.com/app.html", name="x", text="x")) + + +def test_apps_tool_rejects_a_ui_meta_key() -> None: + """SDK-defined: the decorator owns `_meta['ui']` — a caller-supplied `'ui'` entry would be + silently clobbered, so it is rejected at decoration time (use `resource_uri=`/`visibility=`).""" + apps = Apps() + with pytest.raises(ValueError) as exc_info: + apps.tool(resource_uri="ui://c/app.html", meta={"ui": {"resourceUri": "ui://other.html"}}) + assert str(exc_info.value) == snapshot( + "Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key" + ) + + +async def test_add_resource_defaults_the_mime_type_to_the_app_mime() -> None: + """ext-apps: hosts only render `ui://` resources served as `text/html;profile=mcp-app`, + so a resource registered without an explicit `mime_type` gets it by default.""" + apps = Apps() + apps.add_resource(TextResource(uri="ui://d/app.html", name="d", text="d")) + + async with Client(MCPServer("d", extensions=[apps])) as client: + result = await client.read_resource("ui://d/app.html") + + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].mime_type == APP_MIME_TYPE + + +def test_add_resource_rejects_an_explicit_non_app_mime_type() -> None: + """ext-apps: an explicit `mime_type` other than `text/html;profile=mcp-app` would make + the resource unrenderable; the mismatch is rejected at registration.""" + apps = Apps() + with pytest.raises(ValueError) as exc_info: + apps.add_resource(TextResource(uri="ui://e/app.html", name="e", mime_type="text/html", text="x")) + assert str(exc_info.value) == snapshot( + "MCP Apps resources are served as 'text/html;profile=mcp-app', got 'text/html'" + ) diff --git a/tests/server/test_extensions_capability.py b/tests/server/test_extensions_capability.py new file mode 100644 index 000000000..90f24be2b --- /dev/null +++ b/tests/server/test_extensions_capability.py @@ -0,0 +1,134 @@ +"""Tests for the SEP-2133 extensions capability negotiation plumbing. + +The extension-map negotiation is independent of any concrete extension (Apps, +Tasks): the lowlevel `Server` advertises `self.extensions` under +`ServerCapabilities.extensions`, a client mirrors its own support under +`ClientCapabilities.extensions`, and `Connection.check_capability` resolves the +server-side query. These tests pin that plumbing end-to-end and at the unit +level. Per-extension contribution wiring lives in `test_extension.py`; this file +covers only the capability advertisement and negotiation. +""" + +import mcp_types as types +import pytest +from inline_snapshot import snapshot + +from mcp.client.client import Client +from mcp.server import Server, ServerRequestContext +from mcp.server.extension import Extension +from mcp.server.mcpserver import MCPServer + +pytestmark = pytest.mark.anyio + +_EXTENSION_ID = "com.example/x" +_OTHER_EXTENSION_ID = "com.example/other" + + +class _Extension(Extension): + identifier = _EXTENSION_ID + + def settings(self) -> dict[str, object]: + return {"k": 1} + + +def test_get_capabilities_omits_extensions_when_none_registered() -> None: + """SDK-defined: a lowlevel `Server` with an empty `extensions` map advertises + `ServerCapabilities.extensions` as `None`, not an empty map.""" + server = Server("bare") + assert server.get_capabilities().extensions is None + + +def test_get_capabilities_advertises_populated_self_extensions() -> None: + """SDK-defined: `get_capabilities` reads `self.extensions` (the map higher + layers populate) and advertises it under `ServerCapabilities.extensions`.""" + server = Server("with-ext") + settings = {"k": 1} + server.extensions = {_EXTENSION_ID: settings} + assert server.get_capabilities().extensions == {_EXTENSION_ID: settings} + + +async def test_modern_connection_carries_the_advertised_extensions_map() -> None: + """SDK-defined: over a modern (`server/discover`) connection the client reads + the server's advertised extension map from `server_capabilities`.""" + server = MCPServer("host", extensions=[_Extension()]) + async with Client(server, mode="auto") as client: + assert client.server_capabilities.extensions == snapshot({"com.example/x": {"k": 1}}) + + +async def test_legacy_handshake_drops_the_extensions_map() -> None: + """Pinned gap: the handshake-era `initialize` result is serialized against the + 2025 wire schema, which has no `extensions` field, so a legacy handshake cannot + carry it; the client sees `None` even though the server advertised one.""" + server = MCPServer("host", extensions=[_Extension()]) + async with Client(server, mode="legacy") as client: + assert client.server_capabilities.extensions is None + + +async def test_server_accepts_capability_for_client_advertised_extension() -> None: + """SDK-defined: a client advertising `extensions={id: ...}` makes the + server-side `check_client_capability` return True when queried for that id. + Observed inside a tool handler.""" + queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}}) + supported: list[bool] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "probe" + supported.append(ctx.session.check_client_capability(queried)) + return types.CallToolResult(content=[]) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools) + async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client: + await client.call_tool("probe", {}) + + assert supported == [True] + + +async def test_server_rejects_capability_for_undeclared_extension() -> None: + """SDK-defined: when the client advertises one extension, a server query for a + *different* identifier returns False - presence, not value, is the check.""" + queried = types.ClientCapabilities(extensions={_OTHER_EXTENSION_ID: {}}) + supported: list[bool] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "probe" + supported.append(ctx.session.check_client_capability(queried)) + return types.CallToolResult(content=[]) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools) + async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client: + await client.call_tool("probe", {}) + + assert supported == [False] + + +async def test_server_rejects_capability_when_client_advertises_no_extensions() -> None: + """SDK-defined: a client that declares no extensions makes any server + `check_client_capability` query for an extension return False.""" + queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}}) + supported: list[bool] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "probe" + supported.append(ctx.session.check_client_capability(queried)) + return types.CallToolResult(content=[]) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools) + async with Client(server) as client: + await client.call_tool("probe", {}) + + assert supported == [False]