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:
Marcelo Trylesinski 2026-06-29 11:58:05 +02:00 • committed by GitHub
commit 4b519782f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 2598 additions and 24 deletions

100
tests/docs_src/test_apps.py Normal file
View 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()

View 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"]

View file

@ -0,0 +1,485 @@
"""Tests for the core SEP-2133 extension API (`Extension`, `MCPServer` wiring).
These exercise the closed set of extension contribution kinds - tools,
resources, request methods, and the single `tools/call` interceptor - through
the highest-level public surface (in-memory `Client`), plus the
`compose_tool_call_interceptor` helper directly.
"""
from typing import Any, Literal, cast
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
CallToolResult,
TextContent,
)
from mcp.client.client import Client
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import (
Extension,
MethodBinding,
ResourceBinding,
ToolBinding,
compose_tool_call_interceptor,
validate_extension_identifier,
)
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from mcp.server.mcpserver.resources import TextResource
from mcp.shared.exceptions import MCPError
pytestmark = pytest.mark.anyio
_TOOL_META: dict[str, Any] = {"com.example/marker": {"v": 1}}
class _AdditiveExt(Extension):
"""Override `tools()`/`resources()` only - a purely additive extension."""
identifier = "com.example/additive"
def tools(self):
def ping() -> str:
"""Reply with pong."""
return "pong"
return [ToolBinding(fn=ping, meta=_TOOL_META)]
def resources(self):
return [ResourceBinding(resource=TextResource(uri="ext://greeting", name="greeting", text="hello"))]
class _SettingsExt(Extension):
"""Override `settings()` so the extension advertises a non-empty settings map."""
identifier = "com.example/settings"
def settings(self) -> dict[str, Any]:
return {"feature": {"enabled": True}}
class _PingParams(types.RequestParams):
pass
class _PingResult(types.Result):
pong: bool
class _PingRequest(types.Request[_PingParams, Literal["com.example/ping"]]):
method: Literal["com.example/ping"] = "com.example/ping"
params: _PingParams
async def _pong_handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult:
"""The shared `com.example/ping` handler (dispatched by the reachability test)."""
return _PingResult(pong=True)
class _MethodExt(Extension):
"""Override `methods()` to serve a new vendor request verb."""
identifier = "com.example/method"
def methods(self) -> list[MethodBinding]:
return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
class _ReplacingExt(Extension):
"""Override `intercept_tool_call()` to short-circuit with a fixed result."""
identifier = "com.example/replacing"
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
return CallToolResult(content=[TextContent(type="text", text="intercepted")])
class _PassThroughExt(Extension):
"""Override `intercept_tool_call()` but always delegate to `call_next` unchanged."""
identifier = "com.example/passthrough"
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
return await call_next(ctx)
class _DefaultExt(Extension):
"""Override nothing - relies on the base `intercept_tool_call` default (pass through)."""
identifier = "com.example/default"
class _RecordingExt(Extension):
"""Override `intercept_tool_call()` to record `(identifier, tool_name)` then pass through."""
def __init__(self, identifier: str, log: list[tuple[str, str]]) -> None:
self.identifier = identifier
self._log = log
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
self._log.append((self.identifier, params.name))
return await call_next(ctx)
def _echo(value: str) -> str:
"""Echo the input value (shared tool body across interceptor tests)."""
return value
async def test_additive_extension_registers_its_tool_and_resource() -> None:
"""SDK-defined: an `Extension` overriding `tools()`/`resources()` surfaces both
through `MCPServer`'s normal `list_tools`/`list_resources`, and the tool's
`_meta` round-trips equal to the exact dict the binding carried (identity can't
hold - the value is JSON-serialized over the transport)."""
server = MCPServer("test", extensions=[_AdditiveExt()])
async with Client(server) as client:
tools = await client.list_tools()
resources = await client.list_resources()
called = await client.call_tool("ping", {})
assert [t.name for t in tools.tools] == ["ping"]
assert tools.tools[0].meta == _TOOL_META
assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"}))
assert resources == snapshot(
types.ListResourcesResult(
resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")]
)
)
async def test_extension_settings_advertised_under_server_capabilities() -> None:
"""SDK-defined: `settings()` rides `server/discover` and lands under
`server_capabilities.extensions[identifier]` on the modern (`auto`) path."""
server = MCPServer("test", extensions=[_SettingsExt()])
async with Client(server, mode="auto") as client:
extensions = client.server_capabilities.extensions
assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}})
async def test_extension_settings_dropped_on_legacy_handshake() -> None:
"""Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions`
field, so a legacy `initialize` handshake drops the advertised extension even
though the modern `auto` path carries it."""
server = MCPServer("test", extensions=[_SettingsExt()])
async with Client(server, mode="legacy") as client:
assert client.server_capabilities.extensions is None
def test_duplicate_extension_identifier_raises() -> None:
"""SDK-defined: registering two extensions with the same `identifier` is a
construction error."""
with pytest.raises(ValueError):
MCPServer("test", extensions=[_SettingsExt(), _SettingsExt()])
async def test_extension_method_reachable_via_session_send_request() -> None:
"""SDK-defined: an `Extension` overriding `methods()` wires a new request verb
onto the low-level server, reachable through `client.session.send_request`."""
server = MCPServer("test", extensions=[_MethodExt()])
async with Client(server) as client:
request = _PingRequest(params=_PingParams())
result = await client.session.send_request(cast("types.ClientRequest", request), _PingResult)
assert result == snapshot(_PingResult(pong=True))
async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None:
"""SDK-defined: an extension whose `intercept_tool_call` delegates to
`call_next` does not alter the underlying tool's `CallToolResult`."""
server = MCPServer("test", extensions=[_PassThroughExt()])
server.tool(name="echo")(_echo)
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
"""SDK-defined: an extension that returns from `intercept_tool_call` without
calling `call_next` replaces the tool's result wholesale (the tool never runs)."""
server = MCPServer("test", extensions=[_ReplacingExt()])
server.tool(name="echo", structured_output=False)(_echo)
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")]))
def test_plain_extension_installs_no_tool_call_interceptor() -> None:
"""SDK-defined: an extension that does not override `intercept_tool_call` adds no
middleware - the composed interceptor exists only when at least one extension
overrides it."""
baseline = len(MCPServer("test")._lowlevel_server.middleware)
server = MCPServer("test", extensions=[_AdditiveExt()])
assert len(server._lowlevel_server.middleware) == baseline
def test_overriding_extension_installs_one_tool_call_interceptor() -> None:
"""SDK-defined: an extension that overrides `intercept_tool_call` composes exactly
one additional `tools/call` middleware."""
baseline = len(MCPServer("test")._lowlevel_server.middleware)
server = MCPServer("test", extensions=[_ReplacingExt()])
assert len(server._lowlevel_server.middleware) == baseline + 1
async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None:
"""SDK-defined: an extension that does not override `intercept_tool_call` runs the
base-class default (pass through) when another extension forces the composed
middleware to exist, leaving the tool result untouched."""
server = MCPServer("test", extensions=[_DefaultExt(), _PassThroughExt()])
server.tool(name="echo")(_echo)
async with Client(server) as client:
result = await client.call_tool("echo", {"value": "hi"})
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
async def test_interceptors_run_in_registration_order_with_threaded_params() -> None:
"""SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so
two passing-through interceptors record in registration order, each seeing the
validated `tools/call` params (the real tool name)."""
log: list[tuple[str, str]] = []
server = MCPServer(
"test",
extensions=[_RecordingExt("com.example/first", log), _RecordingExt("com.example/second", log)],
)
server.tool(name="echo")(_echo)
async with Client(server) as client:
await client.call_tool("echo", {"value": "hi"})
assert log == [("com.example/first", "echo"), ("com.example/second", "echo")]
async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None:
"""SDK-defined: the composed middleware is a no-op for any method other than
`tools/call` - it forwards to `call_next` without touching the interceptors."""
sentinel = types.EmptyResult()
async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
return sentinel
middleware = compose_tool_call_interceptor([_ReplacingExt()])
ctx = ServerRequestContext(
session=cast("Any", None),
lifespan_context={},
protocol_version="2026-07-28",
method="tasks/get",
params={"taskId": "t-1"},
)
result = await middleware(ctx, call_next)
assert result is sentinel
def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None:
"""SDK-defined: SEP-2133 requires a `vendor-prefix/name` identifier, enforced when the
subclass is defined (a bare name with no prefix is a TypeError)."""
with pytest.raises(TypeError):
type("_BadExt", (Extension,), {"identifier": "noprefix"})
def test_extension_without_identifier_is_rejected_at_registration() -> None:
"""SDK-defined: a subclass that never sets `identifier` (neither class-level nor in
`__init__`) is rejected when the server applies it."""
class _NoIdExt(Extension):
pass
with pytest.raises(TypeError):
MCPServer("test", extensions=[_NoIdExt()])
class _VersionPinnedParams(types.RequestParams):
pass
class _VersionPinnedResult(types.Result):
ok: bool
class _VersionPinnedRequest(types.Request[_VersionPinnedParams, Literal["com.example/pinned"]]):
method: Literal["com.example/pinned"] = "com.example/pinned"
params: _VersionPinnedParams
class _VersionPinnedExt(Extension):
"""A method scoped to 2026-07-28 only via `MethodBinding.protocol_versions`."""
identifier = "com.example/pinned"
def methods(self):
async def handler(ctx: ServerRequestContext[Any, Any], params: _VersionPinnedParams) -> _VersionPinnedResult:
return _VersionPinnedResult(ok=True)
return [MethodBinding("com.example/pinned", _VersionPinnedParams, handler, frozenset({"2026-07-28"}))]
async def test_version_pinned_method_is_served_at_an_allowed_version() -> None:
"""SDK-defined: a `MethodBinding` with `protocol_versions` serves the method at a version
in the set."""
server = MCPServer("test", extensions=[_VersionPinnedExt()])
async with Client(server, mode="2026-07-28") as client:
request = _VersionPinnedRequest(params=_VersionPinnedParams())
result = await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult)
assert result == snapshot(_VersionPinnedResult(ok=True))
async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None:
"""SDK-defined: the same method at a version outside `protocol_versions` is rejected with
METHOD_NOT_FOUND, mirroring the spec's per-version boundary."""
server = MCPServer("test", extensions=[_VersionPinnedExt()])
async with Client(server, mode="legacy") as client:
request = _VersionPinnedRequest(params=_VersionPinnedParams())
with pytest.raises(MCPError) as exc_info:
await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult)
assert exc_info.value.code == METHOD_NOT_FOUND
assert exc_info.value.error.data == "com.example/pinned"
@pytest.mark.parametrize(
"identifier",
[
"io.modelcontextprotocol/ui",
"com.example/my_ext",
"com.x-y.z2/n.a-b_c",
"example/x",
"a/b",
"com.example/9start",
],
)
def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None:
"""Spec `_meta` key grammar: dot-separated labels (letter start, letter/digit end,
hyphens interior), a slash, then a name that starts and ends alphanumeric."""
validate_extension_identifier(identifier, owner="T")
@pytest.mark.parametrize(
"identifier",
[
"noprefix",
"-foo/bar",
".leading/x",
"a..b/x",
"foo-/x",
"9foo/x",
"foo/-bar",
"foo/bar-",
"foo/",
"/bar",
"foo/ba r",
"io.modelcontextprotocol/ui\n",
"",
None,
42,
],
)
def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None:
"""Spec `_meta` key grammar: malformed prefixes (bad label start/end, empty labels)
and malformed names are rejected, as are non-strings."""
with pytest.raises(TypeError):
validate_extension_identifier(identifier, owner="T")
@pytest.mark.parametrize("method", ["tools/list", "completion/complete"])
def test_method_binding_rejects_spec_methods(method: str) -> None:
"""SDK-defined: extension methods are additive — binding a spec-defined request method
would silently shadow (or be shadowed by) the server's own handler, so it is rejected
when the binding is constructed."""
with pytest.raises(ValueError):
MethodBinding(method, _PingParams, _pong_handler)
def test_method_binding_rejects_empty_protocol_versions() -> None:
"""SDK-defined: an empty `protocol_versions` set would make the method unreachable at
every version; `None` is the universal-version spelling."""
with pytest.raises(ValueError) as exc_info:
MethodBinding("com.example/dead", _PingParams, _pong_handler, frozenset())
assert str(exc_info.value) == snapshot(
"MethodBinding for 'com.example/dead' has an empty protocol_versions set, so it could "
"never be served; use None to admit every version"
)
class _OtherMethodExt(Extension):
"""A second extension binding the same verb as `_MethodExt`."""
identifier = "com.example/other-method"
def methods(self) -> list[MethodBinding]:
return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
def test_colliding_extension_methods_are_rejected_at_registration() -> None:
"""SDK-defined: two extensions binding the same method would silently last-write-win;
the collision is rejected when the second extension is applied."""
with pytest.raises(ValueError) as exc_info:
MCPServer("test", extensions=[_MethodExt(), _OtherMethodExt()])
assert str(exc_info.value) == snapshot(
"Extension 'com.example/other-method' binds method 'com.example/ping', which is already "
"registered; extension methods are additive and cannot replace another handler"
)
_NEEDS_EXT = "com.example/needed"
class _RequiresExt(Extension):
"""A tool that requires the client to have declared `com.example/needed`."""
identifier = _NEEDS_EXT
def tools(self):
def guarded(ctx: Context) -> str:
require_client_extension(ctx.request_context, _NEEDS_EXT)
return "ok"
return [ToolBinding(fn=guarded)]
async def test_require_client_extension_passes_when_client_declared_it() -> None:
"""SDK-defined: `require_client_extension` is a no-op when the client advertised the id."""
server = MCPServer("test", extensions=[_RequiresExt()])
async with Client(server, extensions={_NEEDS_EXT: {}}) as client:
result = await client.call_tool("guarded", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None:
"""SDK-defined: `require_client_extension` raises the -32021 missing-required-capability
error when the client did not advertise the id."""
server = MCPServer("test", extensions=[_RequiresExt()])
async with Client(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("guarded", {})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert exc_info.value.error.data == snapshot({"requiredCapabilities": {"extensions": {_NEEDS_EXT: {}}}})

302
tests/server/test_apps.py Normal file
View file

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

View file

@ -0,0 +1,134 @@
"""Tests for the SEP-2133 extensions capability negotiation plumbing.
The extension-map negotiation is independent of any concrete extension (Apps,
Tasks): the lowlevel `Server` advertises `self.extensions` under
`ServerCapabilities.extensions`, a client mirrors its own support under
`ClientCapabilities.extensions`, and `Connection.check_capability` resolves the
server-side query. These tests pin that plumbing end-to-end and at the unit
level. Per-extension contribution wiring lives in `test_extension.py`; this file
covers only the capability advertisement and negotiation.
"""
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp.client.client import Client
from mcp.server import Server, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import MCPServer
pytestmark = pytest.mark.anyio
_EXTENSION_ID = "com.example/x"
_OTHER_EXTENSION_ID = "com.example/other"
class _Extension(Extension):
identifier = _EXTENSION_ID
def settings(self) -> dict[str, object]:
return {"k": 1}
def test_get_capabilities_omits_extensions_when_none_registered() -> None:
"""SDK-defined: a lowlevel `Server` with an empty `extensions` map advertises
`ServerCapabilities.extensions` as `None`, not an empty map."""
server = Server("bare")
assert server.get_capabilities().extensions is None
def test_get_capabilities_advertises_populated_self_extensions() -> None:
"""SDK-defined: `get_capabilities` reads `self.extensions` (the map higher
layers populate) and advertises it under `ServerCapabilities.extensions`."""
server = Server("with-ext")
settings = {"k": 1}
server.extensions = {_EXTENSION_ID: settings}
assert server.get_capabilities().extensions == {_EXTENSION_ID: settings}
async def test_modern_connection_carries_the_advertised_extensions_map() -> None:
"""SDK-defined: over a modern (`server/discover`) connection the client reads
the server's advertised extension map from `server_capabilities`."""
server = MCPServer("host", extensions=[_Extension()])
async with Client(server, mode="auto") as client:
assert client.server_capabilities.extensions == snapshot({"com.example/x": {"k": 1}})
async def test_legacy_handshake_drops_the_extensions_map() -> None:
"""Pinned gap: the handshake-era `initialize` result is serialized against the
2025 wire schema, which has no `extensions` field, so a legacy handshake cannot
carry it; the client sees `None` even though the server advertised one."""
server = MCPServer("host", extensions=[_Extension()])
async with Client(server, mode="legacy") as client:
assert client.server_capabilities.extensions is None
async def test_server_accepts_capability_for_client_advertised_extension() -> None:
"""SDK-defined: a client advertising `extensions={id: ...}` makes the
server-side `check_client_capability` return True when queried for that id.
Observed inside a tool handler."""
queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}})
supported: list[bool] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "probe"
supported.append(ctx.session.check_client_capability(queried))
return types.CallToolResult(content=[])
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
await client.call_tool("probe", {})
assert supported == [True]
async def test_server_rejects_capability_for_undeclared_extension() -> None:
"""SDK-defined: when the client advertises one extension, a server query for a
*different* identifier returns False - presence, not value, is the check."""
queried = types.ClientCapabilities(extensions={_OTHER_EXTENSION_ID: {}})
supported: list[bool] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "probe"
supported.append(ctx.session.check_client_capability(queried))
return types.CallToolResult(content=[])
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
await client.call_tool("probe", {})
assert supported == [False]
async def test_server_rejects_capability_when_client_advertises_no_extensions() -> None:
"""SDK-defined: a client that declares no extensions makes any server
`check_client_capability` query for an extension return False."""
queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}})
supported: list[bool] = []
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "probe"
supported.append(ctx.session.check_client_capability(queried))
return types.CallToolResult(content=[])
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
async with Client(server) as client:
await client.call_tool("probe", {})
assert supported == [False]