"""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 import advertise 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=[advertise(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(ui_settings: 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) extensions = None if ui_settings is None else [advertise(EXTENSION_ID, ui_settings)] async with Client(server, extensions=extensions) as client: await client.call_tool("probe", {}) return observed[0] @pytest.mark.parametrize( ("ui_settings", "expected"), [ pytest.param({"mimeTypes": [APP_MIME_TYPE]}, True, id="html-mime-listed"), pytest.param({"mimeTypes": (APP_MIME_TYPE,)}, True, id="in-process-tuple-mime-types"), pytest.param(None, False, id="extension-not-declared"), pytest.param({"mimeTypes": ["application/x-other"]}, False, id="html-mime-not-offered"), pytest.param({}, False, id="mime-types-key-missing"), ], ) async def test_client_supports_apps_from_lowlevel_request_context( ui_settings: 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(ui_settings) 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'" )