Add a pluggable server extension API with MCP Apps (#3003)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
fe9723853d
commit
4b519782f1
2 changed files with 2598 additions and 24 deletions
100
tests/docs_src/test_apps.py
Normal file
100
tests/docs_src/test_apps.py
Normal file
|
|
@ -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()
|
||||
97
tests/docs_src/test_extensions.py
Normal file
97
tests/docs_src/test_extensions.py
Normal file
|
|
@ -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"]
|
||||
Loading…
Reference in a new issue