Files
modelcontextprotocol--pytho…/tests/server/test_apps.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

305 lines
13 KiB
Python

"""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", "<title>Clock</title>", 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="<title>Clock</title>",
)
]
)
)
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", "<title>x</title>")
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", "<title>v</title>")
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", "<title>m</title>")
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",
"<title>r</title>",
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="<title>p</title>")
)
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 == "<title>p</title>"
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="<title>d</title>"))
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'"
)