-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Expand file tree
/
Copy pathtest_apps.py
More file actions
305 lines (241 loc) · 13.2 KB
/
Copy pathtest_apps.py
File metadata and controls
305 lines (241 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
"""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(
_meta={"io.modelcontextprotocol/serverInfo": {"name": "clock", "version": ""}},
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'"
)