"""MCP Apps support — extension negotiation and typed UI metadata models. Provides constants and Pydantic models for the MCP Apps extension (io.modelcontextprotocol/ui), enabling tools and resources to carry UI metadata for clients that support interactive app rendering. """ from __future__ import annotations from typing import Any, Literal from pydantic import BaseModel, Field from fastmcp.utilities.mime import UI_MIME_TYPE as UI_MIME_TYPE from fastmcp.utilities.mime import resolve_ui_mime_type as resolve_ui_mime_type UI_EXTENSION_ID = "io.modelcontextprotocol/ui" class ResourceCSP(BaseModel): """Content Security Policy for MCP App resources. Declares which external origins the app is allowed to connect to or load resources from. Hosts use these declarations to build the ``Content-Security-Policy`` header for the sandboxed iframe. """ connect_domains: list[str] | None = Field( default=None, validation_alias="connectDomains", serialization_alias="connectDomains", description="Origins allowed for fetch/XHR/WebSocket (connect-src)", ) resource_domains: list[str] | None = Field( default=None, validation_alias="resourceDomains", serialization_alias="resourceDomains", description="Origins allowed for scripts, images, styles, fonts (script-src etc.)", ) frame_domains: list[str] | None = Field( default=None, validation_alias="frameDomains", serialization_alias="frameDomains", description="Origins allowed for nested iframes (frame-src)", ) base_uri_domains: list[str] | None = Field( default=None, validation_alias="baseUriDomains", serialization_alias="baseUriDomains", description="Allowed base URIs for the document (base-uri)", ) model_config = {"populate_by_name": True, "extra": "allow"} class ResourcePermissions(BaseModel): """Iframe sandbox permissions for MCP App resources. Each field, when set (typically to ``{}``), requests that the host grant the corresponding Permission Policy feature to the sandboxed iframe. Hosts MAY honour these; apps should use JS feature detection as a fallback. """ camera: dict[str, Any] | None = Field( default=None, description="Request camera access" ) microphone: dict[str, Any] | None = Field( default=None, description="Request microphone access" ) geolocation: dict[str, Any] | None = Field( default=None, description="Request geolocation access" ) clipboard_write: dict[str, Any] | None = Field( default=None, validation_alias="clipboardWrite", serialization_alias="clipboardWrite", description="Request clipboard-write access", ) model_config = {"populate_by_name": True, "extra": "allow"} class AppConfig(BaseModel): """Configuration for MCP App tools and resources. Controls how a tool or resource participates in the MCP Apps extension. On tools, ``resource_uri`` and ``visibility`` specify which UI resource to render and where the tool appears. On resources, those fields must be left unset (the resource itself is the UI). All fields use ``exclude_none`` serialization so only explicitly-set values appear on the wire. Aliases match the MCP Apps wire format (camelCase). """ resource_uri: str | None = Field( default=None, validation_alias="resourceUri", serialization_alias="resourceUri", description="URI of the UI resource (typically ui:// scheme). Tools only.", ) visibility: list[Literal["app", "model"]] | None = Field( default=None, description="Where this tool is visible: 'app', 'model', or both. Tools only.", ) csp: ResourceCSP | None = Field( default=None, description="Content Security Policy for the app iframe" ) permissions: ResourcePermissions | None = Field( default=None, description="Iframe sandbox permissions" ) domain: str | None = Field(default=None, description="Domain for the iframe") prefers_border: bool | None = Field( default=None, validation_alias="prefersBorder", serialization_alias="prefersBorder", description="Whether the UI prefers a visible border", ) model_config = {"populate_by_name": True, "extra": "allow"} class PrefabAppConfig(AppConfig): """App configuration for Prefab tools with sensible defaults. Like ``app=True`` but customizable. Auto-wires the Prefab renderer URI and merges the renderer's CSP with any additional domains you specify. The renderer resource is registered automatically. Example:: @mcp.tool(app=PrefabAppConfig()) # same as app=True @mcp.tool(app=PrefabAppConfig( csp=ResourceCSP(frame_domains=["https://example.com"]), )) """ def model_post_init(self, __context: Any) -> None: # Set the renderer URI if not explicitly overridden if self.resource_uri is None: self.resource_uri = "ui://prefab/renderer.html" # Merge renderer CSP with user-provided CSP try: from prefab_ui.renderer import get_renderer_csp renderer_csp = get_renderer_csp() except ImportError: renderer_csp = {} if renderer_csp: user_csp = self.csp or ResourceCSP() # Start from the user's CSP (preserves model_extra for # forward-compat directives), then merge renderer domains. merged_data = user_csp.model_dump(exclude_none=True) merged_data["connect_domains"] = _merge_domains( renderer_csp.get("connect_domains"), user_csp.connect_domains, ) merged_data["resource_domains"] = _merge_domains( renderer_csp.get("resource_domains"), user_csp.resource_domains, ) self.csp = ResourceCSP(**merged_data) def _merge_domains(base: list[str] | None, extra: list[str] | None) -> list[str] | None: """Merge two domain lists, deduplicating.""" if base is None and extra is None: return None combined = list(base or []) for d in extra or []: if d not in combined: combined.append(d) return combined or None def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]: """Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``.""" if isinstance(app, AppConfig): return app.model_dump(by_alias=True, exclude_none=True) return app