242 lines
9.5 KiB
Python
242 lines
9.5 KiB
Python
"""MCP Apps extension (`io.modelcontextprotocol/ui`).
|
|
|
|
MCP Apps lets a tool carry a reference to an interactive UI: the tool's
|
|
`_meta.ui.resourceUri` points at a `ui://` resource (an HTML document served
|
|
with the `text/html;profile=mcp-app` MIME type) that the host renders in a
|
|
sandboxed iframe. See https://modelcontextprotocol.io/specification/draft/extensions/apps
|
|
and the ext-apps spec for the wire format, and SEP-2133 for the extension framework.
|
|
|
|
This is a self-contained, additive `Extension`: it contributes tools and
|
|
resources and advertises the capability, but does not intercept any core method.
|
|
A server opts in by passing an `Apps` instance to `MCPServer(extensions=[...])`.
|
|
|
|
apps = Apps()
|
|
|
|
@apps.tool(resource_uri="ui://clock/app.html", description="Current time")
|
|
def get_time(ctx: Context) -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)
|
|
|
|
mcp = MCPServer("clock", extensions=[apps])
|
|
|
|
Per SEP-2133, an extension MUST degrade gracefully: a UI-enabled tool should
|
|
still return meaningful text for clients that did not negotiate Apps. Use
|
|
`client_supports_apps(ctx)` to branch on the client's advertised support. (The SDK
|
|
keeps Apps in-core under `mcp.server.apps` rather than a separate package; the
|
|
TypeScript and C# SDKs ship it as a standalone package.)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Sequence
|
|
from typing import Any, Literal, TypeVar
|
|
|
|
from pydantic import BaseModel, ConfigDict
|
|
from pydantic.alias_generators import to_camel
|
|
|
|
from mcp.server.context import ServerRequestContext
|
|
from mcp.server.extension import Extension, ResourceBinding, ToolBinding
|
|
from mcp.server.mcpserver.context import Context
|
|
from mcp.server.mcpserver.resources import Resource, TextResource
|
|
|
|
EXTENSION_ID = "io.modelcontextprotocol/ui"
|
|
"""The MCP Apps extension identifier (the shipped TS/C# constant)."""
|
|
|
|
APP_MIME_TYPE = "text/html;profile=mcp-app"
|
|
"""MIME type for a `ui://` app resource."""
|
|
|
|
Visibility = Literal["model", "app"]
|
|
"""Where a UI-bound tool is surfaced (`_meta.ui.visibility`)."""
|
|
|
|
_CallableT = TypeVar("_CallableT", bound=Callable[..., Any])
|
|
|
|
|
|
class ResourcePermissions(BaseModel):
|
|
"""Iframe permissions a `ui://` resource requests (`_meta.ui.permissions`)."""
|
|
|
|
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
|
|
camera: dict[str, Any] | None = None
|
|
microphone: dict[str, Any] | None = None
|
|
geolocation: dict[str, Any] | None = None
|
|
clipboard_write: dict[str, Any] | None = None
|
|
|
|
|
|
class ResourceCsp(BaseModel):
|
|
"""Content-Security-Policy domains for a `ui://` resource (`_meta.ui.csp`)."""
|
|
|
|
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
|
|
|
connect_domains: list[str] | None = None
|
|
resource_domains: list[str] | None = None
|
|
frame_domains: list[str] | None = None
|
|
base_uri_domains: list[str] | None = None
|
|
|
|
|
|
class Apps(Extension):
|
|
"""The MCP Apps extension: bind tools to `ui://` UI resources.
|
|
|
|
Register UI-bound tools with `@apps.tool(resource_uri=...)` and their HTML
|
|
with `add_html_resource(...)`, then pass the instance to
|
|
`MCPServer(extensions=[apps])`.
|
|
"""
|
|
|
|
identifier = EXTENSION_ID
|
|
|
|
def __init__(self) -> None:
|
|
self._tools: list[tuple[ToolBinding, str]] = [] # (binding, bound resource_uri)
|
|
self._resources: list[ResourceBinding] = []
|
|
|
|
def tool(
|
|
self,
|
|
*,
|
|
resource_uri: str,
|
|
visibility: Sequence[Visibility] | None = None,
|
|
meta: dict[str, Any] | None = None,
|
|
**tool_kwargs: Any,
|
|
) -> Callable[[_CallableT], _CallableT]:
|
|
"""Decorator registering a tool bound to a `ui://` resource.
|
|
|
|
Stamps `_meta.ui.resourceUri` (and `_meta.ui.visibility` when given) on the
|
|
tool. `tool_kwargs` are forwarded to `MCPServer.add_tool` (name, title,
|
|
description, annotations, ...); pass `meta=` to merge extra `_meta` keys
|
|
alongside the `ui` entry.
|
|
|
|
Args:
|
|
resource_uri: The `ui://` URI of the UI resource this tool renders.
|
|
visibility: Where the tool is surfaced (`["model", "app"]`).
|
|
meta: Additional `_meta` keys to merge with the `ui` entry.
|
|
|
|
Raises:
|
|
ValueError: If `resource_uri` does not use the `ui://` scheme, or
|
|
`meta` carries a `"ui"` key (the decorator owns `_meta["ui"]`).
|
|
"""
|
|
_require_ui_scheme(resource_uri)
|
|
if meta and "ui" in meta:
|
|
raise ValueError("Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key")
|
|
ui: dict[str, Any] = {"resourceUri": resource_uri}
|
|
if visibility is not None:
|
|
ui["visibility"] = list(visibility)
|
|
|
|
def decorator(fn: _CallableT) -> _CallableT:
|
|
binding = ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs)
|
|
self._tools.append((binding, resource_uri))
|
|
return fn
|
|
|
|
return decorator
|
|
|
|
def add_html_resource(
|
|
self,
|
|
uri: str,
|
|
html: str,
|
|
*,
|
|
name: str | None = None,
|
|
title: str | None = None,
|
|
description: str | None = None,
|
|
csp: ResourceCsp | None = None,
|
|
permissions: ResourcePermissions | None = None,
|
|
domain: str | None = None,
|
|
prefers_border: bool | None = None,
|
|
) -> None:
|
|
"""Register a `ui://` HTML resource served as `text/html;profile=mcp-app`.
|
|
|
|
`csp`, `permissions`, `domain`, and `prefers_border` populate the
|
|
resource's `_meta.ui` per the ext-apps spec.
|
|
|
|
Args:
|
|
uri: The `ui://` URI; a tool references it via `resource_uri`.
|
|
html: The HTML document the host renders.
|
|
|
|
Raises:
|
|
ValueError: If `uri` does not use the `ui://` scheme.
|
|
"""
|
|
ui: dict[str, Any] = {}
|
|
if csp is not None:
|
|
ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True)
|
|
if permissions is not None:
|
|
ui["permissions"] = permissions.model_dump(by_alias=True, exclude_none=True)
|
|
if domain is not None:
|
|
ui["domain"] = domain
|
|
if prefers_border is not None:
|
|
ui["prefersBorder"] = prefers_border
|
|
self.add_resource(
|
|
TextResource(
|
|
uri=uri,
|
|
name=name or uri,
|
|
title=title,
|
|
description=description,
|
|
mime_type=APP_MIME_TYPE,
|
|
meta={"ui": ui} if ui else None,
|
|
text=html,
|
|
)
|
|
)
|
|
|
|
def add_resource(self, resource: Resource) -> None:
|
|
"""Register a pre-built `ui://` resource.
|
|
|
|
The escape hatch for resources `add_html_resource` cannot express (e.g. a
|
|
`FileResource` serving HTML from disk). A resource without an explicit
|
|
`mime_type` is served as `text/html;profile=mcp-app` — hosts will not
|
|
render a `ui://` resource under any other MIME type, so an explicit
|
|
mismatch is rejected.
|
|
|
|
Raises:
|
|
ValueError: If the resource URI does not use the `ui://` scheme, or
|
|
its explicit `mime_type` is not `text/html;profile=mcp-app`.
|
|
"""
|
|
_require_ui_scheme(resource.uri)
|
|
if "mime_type" not in resource.model_fields_set:
|
|
resource = resource.model_copy(update={"mime_type": APP_MIME_TYPE})
|
|
elif resource.mime_type != APP_MIME_TYPE:
|
|
raise ValueError(f"MCP Apps resources are served as {APP_MIME_TYPE!r}, got {resource.mime_type!r}")
|
|
self._resources.append(ResourceBinding(resource=resource))
|
|
|
|
def tools(self) -> Sequence[ToolBinding]:
|
|
"""The bound tools.
|
|
|
|
Raises:
|
|
ValueError: If a tool's `resource_uri` has no matching resource
|
|
registered on this instance — a tool advertising a
|
|
`_meta.ui.resourceUri` that 404s on `resources/read` is a
|
|
misconfiguration, caught when the server consumes the extension.
|
|
"""
|
|
registered = {binding.resource.uri for binding in self._resources}
|
|
for tool, uri in self._tools:
|
|
if uri not in registered:
|
|
raise ValueError(
|
|
f"Apps tool {tool.fn.__name__!r} binds resource_uri {uri!r}, but no such resource "
|
|
"is registered; add it with add_html_resource() or add_resource()"
|
|
)
|
|
return [tool for tool, _ in self._tools]
|
|
|
|
def resources(self) -> Sequence[ResourceBinding]:
|
|
return self._resources
|
|
|
|
|
|
def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> bool:
|
|
"""Whether the connected client negotiated MCP Apps support.
|
|
|
|
Returns `True` only when the client advertised the extension AND listed the
|
|
`text/html;profile=mcp-app` MIME type in its settings, so a UI-enabled tool
|
|
can fall back to text-only output otherwise.
|
|
"""
|
|
capabilities = _client_capabilities(ctx)
|
|
extensions = capabilities.extensions if capabilities else None
|
|
settings = extensions.get(EXTENSION_ID) if extensions else None
|
|
if settings is None:
|
|
return False
|
|
mime_types = settings.get("mimeTypes")
|
|
return isinstance(mime_types, list | tuple) and APP_MIME_TYPE in mime_types
|
|
|
|
|
|
def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any:
|
|
if isinstance(ctx, Context):
|
|
return ctx.client_capabilities
|
|
return ctx.session.client_capabilities
|
|
|
|
|
|
def _require_ui_scheme(uri: str) -> None:
|
|
if not uri.startswith("ui://"):
|
|
raise ValueError(f"MCP Apps URIs must use the ui:// scheme, got {uri!r}")
|