Skip to content

Commit c8684e9

Browse files
committed
Add the MCP Apps extension (io.modelcontextprotocol/ui)
`Apps` is an additive `Extension`: `@apps.tool(resource_uri=...)` binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, `add_html_resource()` serves the HTML at `text/html;profile=mcp-app`, and `client_supports_apps(ctx)` gates the SEP-2133 text-only fallback. Drop the now-exercised `# pragma: no cover` on `TextResource.read()` (the Apps resource path covers it).
1 parent 68b9c7e commit c8684e9

2 files changed

Lines changed: 273 additions & 0 deletions

File tree

src/mcp/server/apps.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""MCP Apps extension (`io.modelcontextprotocol/ui`).
2+
3+
MCP Apps lets a tool carry a reference to an interactive UI: the tool's
4+
`_meta.ui.resourceUri` points at a `ui://` resource (an HTML document served
5+
with the `text/html;profile=mcp-app` MIME type) that the host renders in a
6+
sandboxed iframe. See https://modelcontextprotocol.io/specification/draft/extensions/apps
7+
and SEP-2133 for the extension framework.
8+
9+
This is a self-contained, additive `Extension`: it contributes tools and
10+
resources and advertises the capability, but does not intercept any core method.
11+
A server opts in by passing an `Apps` instance to `MCPServer(extensions=[...])`.
12+
13+
apps = Apps()
14+
15+
@apps.tool(resource_uri="ui://clock/app.html", description="Current time")
16+
def get_time(ctx: Context) -> str:
17+
return datetime.now(timezone.utc).isoformat()
18+
19+
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)
20+
21+
mcp = MCPServer("clock", extensions=[apps])
22+
23+
Per SEP-2133, an extension MUST degrade gracefully: a UI-enabled tool should
24+
still return meaningful text for clients that did not negotiate Apps. Use
25+
`client_supports_apps(ctx)` to branch on the client's advertised support.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
from collections.abc import Callable, Sequence
31+
from typing import Any, TypeVar
32+
33+
from mcp.server.context import ServerRequestContext
34+
from mcp.server.mcpserver.context import Context
35+
from mcp.server.mcpserver.extension import Extension, ResourceBinding, ToolBinding
36+
from mcp.server.mcpserver.resources import TextResource
37+
38+
EXTENSION_ID = "io.modelcontextprotocol/ui"
39+
"""The MCP Apps extension identifier (the shipped TS/C# constant)."""
40+
41+
APP_MIME_TYPE = "text/html;profile=mcp-app"
42+
"""MIME type for a `ui://` app resource."""
43+
44+
_CallableT = TypeVar("_CallableT", bound=Callable[..., Any])
45+
46+
47+
class Apps(Extension):
48+
"""The MCP Apps extension: bind tools to `ui://` UI resources.
49+
50+
Register UI-bound tools with `@apps.tool(resource_uri=...)` and their HTML
51+
with `add_html_resource(...)`, then pass the instance to
52+
`MCPServer(extensions=[apps])`.
53+
"""
54+
55+
identifier = EXTENSION_ID
56+
57+
def __init__(self) -> None:
58+
self._tools: list[ToolBinding] = []
59+
self._resources: list[ResourceBinding] = []
60+
61+
def tool(self, *, resource_uri: str, **tool_kwargs: Any) -> Callable[[_CallableT], _CallableT]:
62+
"""Decorator registering a tool bound to a `ui://` resource.
63+
64+
Stamps `_meta.ui.resourceUri` on the tool. `tool_kwargs` are forwarded to
65+
`MCPServer.add_tool` (name, title, description, annotations, ...).
66+
67+
Args:
68+
resource_uri: The `ui://` URI of the UI resource this tool renders.
69+
70+
Raises:
71+
ValueError: If `resource_uri` does not use the `ui://` scheme.
72+
"""
73+
_require_ui_scheme(resource_uri)
74+
75+
def decorator(fn: _CallableT) -> _CallableT:
76+
meta = {"ui": {"resourceUri": resource_uri}}
77+
self._tools.append(ToolBinding(fn=fn, meta=meta, kwargs=tool_kwargs))
78+
return fn
79+
80+
return decorator
81+
82+
def add_html_resource(
83+
self,
84+
uri: str,
85+
html: str,
86+
*,
87+
name: str | None = None,
88+
title: str | None = None,
89+
description: str | None = None,
90+
) -> None:
91+
"""Register a `ui://` HTML resource served as `text/html;profile=mcp-app`.
92+
93+
Args:
94+
uri: The `ui://` URI; a tool references it via `resource_uri`.
95+
html: The HTML document the host renders.
96+
97+
Raises:
98+
ValueError: If `uri` does not use the `ui://` scheme.
99+
"""
100+
_require_ui_scheme(uri)
101+
resource = TextResource(
102+
uri=uri,
103+
name=name or uri,
104+
title=title,
105+
description=description,
106+
mime_type=APP_MIME_TYPE,
107+
text=html,
108+
)
109+
self._resources.append(ResourceBinding(resource=resource))
110+
111+
def tools(self) -> Sequence[ToolBinding]:
112+
return self._tools
113+
114+
def resources(self) -> Sequence[ResourceBinding]:
115+
return self._resources
116+
117+
118+
def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> bool:
119+
"""Whether the connected client negotiated MCP Apps support.
120+
121+
Returns `False` when the client did not advertise the extension (or sent no
122+
capabilities), so a UI-enabled tool can fall back to text-only output.
123+
"""
124+
capabilities = _client_capabilities(ctx)
125+
extensions = capabilities.extensions if capabilities else None
126+
return bool(extensions and EXTENSION_ID in extensions)
127+
128+
129+
def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any:
130+
if isinstance(ctx, Context):
131+
return ctx.client_capabilities
132+
client_params = ctx.session.client_params
133+
return client_params.capabilities if client_params else None
134+
135+
136+
def _require_ui_scheme(uri: str) -> None:
137+
if not uri.startswith("ui://"):
138+
raise ValueError(f"MCP Apps URIs must use the ui:// scheme, got {uri!r}")

tests/server/test_apps.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Tests for the MCP Apps extension (`io.modelcontextprotocol/ui`, SEP-2133).
2+
3+
The headline property is SEP-2133 graceful degradation: a UI-bound tool returns
4+
rich output to a client that negotiated Apps and text-only output to one that did
5+
not. The remaining tests pin SDK-defined wiring (the `_meta.ui.resourceUri` stamp,
6+
the `ui://` resource MIME type, capability advertisement, and `ui://`-scheme
7+
validation).
8+
"""
9+
10+
import mcp_types as types
11+
import pytest
12+
from inline_snapshot import snapshot
13+
from mcp_types import CallToolResult, ReadResourceResult, TextContent, TextResourceContents
14+
15+
from mcp.client.client import Client
16+
from mcp.server import Server, ServerRequestContext
17+
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
18+
from mcp.server.mcpserver import MCPServer
19+
from mcp.server.mcpserver.context import Context
20+
21+
pytestmark = pytest.mark.anyio
22+
23+
24+
def _clock_server() -> MCPServer:
25+
apps = Apps()
26+
27+
@apps.tool(resource_uri="ui://clock/app.html", title="Get Time", description="Return the current time.")
28+
def get_time(ctx: Context) -> str:
29+
if not client_supports_apps(ctx):
30+
return "The time is 2026-06-26T00:00:00Z."
31+
return "2026-06-26T00:00:00Z"
32+
33+
apps.add_html_resource("ui://clock/app.html", "<title>Clock</title>", title="Clock")
34+
return MCPServer("clock", extensions=[apps])
35+
36+
37+
async def test_apps_tool_stamps_ui_resource_uri_on_tool_meta() -> None:
38+
"""SDK-defined: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the
39+
advertised tool, observed end-to-end through `list_tools`."""
40+
async with Client(_clock_server()) as client:
41+
result = await client.list_tools()
42+
assert [(t.name, t.meta) for t in result.tools] == snapshot(
43+
[("get_time", {"ui": {"resourceUri": "ui://clock/app.html"}})]
44+
)
45+
46+
47+
async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None:
48+
"""SDK-defined: `add_html_resource` registers the `ui://` resource served as
49+
`text/html;profile=mcp-app`, observed through `read_resource`."""
50+
async with Client(_clock_server()) as client:
51+
result = await client.read_resource("ui://clock/app.html")
52+
assert result == snapshot(
53+
ReadResourceResult(
54+
contents=[
55+
TextResourceContents(
56+
uri="ui://clock/app.html",
57+
mime_type="text/html;profile=mcp-app",
58+
text="<title>Clock</title>",
59+
)
60+
]
61+
)
62+
)
63+
assert isinstance(result.contents[0], TextResourceContents)
64+
assert result.contents[0].mime_type == APP_MIME_TYPE
65+
66+
67+
async def test_auto_mode_carries_apps_extension_under_server_capabilities() -> None:
68+
"""SDK-defined: the Apps extension rides `server/discover`, so a `mode='auto'` client
69+
sees `EXTENSION_ID` under `server_capabilities.extensions`."""
70+
async with Client(_clock_server(), mode="auto") as client:
71+
assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/ui": {}})
72+
73+
74+
async def test_legacy_handshake_drops_apps_extension_from_capabilities() -> None:
75+
"""Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions` field,
76+
so a `mode='legacy'` handshake cannot carry the Apps capability -- only `mode='auto'`
77+
(server/discover) does. This pins the divergence rather than fixing it."""
78+
async with Client(_clock_server(), mode="legacy") as client:
79+
assert client.server_capabilities.extensions is None
80+
81+
82+
async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> None:
83+
"""SEP-2133 graceful degradation: a client that advertised `EXTENSION_ID` gets the
84+
rich (UI) path, while one that did not gets the text-only fallback. The same tool,
85+
branching on `client_supports_apps(ctx)`, drives both halves."""
86+
server = _clock_server()
87+
88+
async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports:
89+
rich = await supports.call_tool("get_time", {})
90+
async with Client(server) as plain:
91+
fallback = await plain.call_tool("get_time", {})
92+
93+
assert rich.content == snapshot([TextContent(text="2026-06-26T00:00:00Z")])
94+
assert fallback.content == snapshot([TextContent(text="The time is 2026-06-26T00:00:00Z.")])
95+
96+
97+
async def test_client_supports_apps_reads_lowlevel_request_context() -> None:
98+
"""SDK-defined: `client_supports_apps` accepts a lowlevel `ServerRequestContext` too,
99+
reading the client's advertised extensions off `session.client_params`."""
100+
observed: list[bool] = []
101+
102+
async def list_tools(
103+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
104+
) -> types.ListToolsResult:
105+
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
106+
107+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
108+
assert params.name == "probe"
109+
observed.append(client_supports_apps(ctx))
110+
return CallToolResult(content=[TextContent(text="ok")])
111+
112+
server = Server("probe", on_list_tools=list_tools, on_call_tool=call_tool)
113+
114+
async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports:
115+
await supports.call_tool("probe", {})
116+
async with Client(server) as plain:
117+
await plain.call_tool("probe", {})
118+
119+
assert observed == [True, False]
120+
121+
122+
def test_apps_tool_rejects_non_ui_resource_uri() -> None:
123+
"""SDK-defined: `@apps.tool` accepts only `ui://` URIs; any other scheme is a
124+
programmer error raised at decoration time."""
125+
apps = Apps()
126+
with pytest.raises(ValueError):
127+
apps.tool(resource_uri="https://example.com/app.html")
128+
129+
130+
def test_add_html_resource_rejects_non_ui_resource_uri() -> None:
131+
"""SDK-defined: `add_html_resource` accepts only `ui://` URIs; any other scheme is
132+
a programmer error raised at registration time."""
133+
apps = Apps()
134+
with pytest.raises(ValueError):
135+
apps.add_html_resource("https://example.com/app.html", "<title>x</title>")

0 commit comments

Comments
 (0)