Files
kefu/wechat_rpa/.venv/Lib/site-packages/mcp/server/apps.py
T
2026-09-21 10:34:06 +08:00

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}")