diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 82f5e39048..974d34009c 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -24,6 +24,75 @@ fastmcp install stdio server.py The command automatically detects the project directory and generates the appropriate `uv run` invocation, making it easy to integrate FastMCP servers with MCP clients. +### MCP Apps (SDK Compatibility) + +Support for [MCP Apps](https://modelcontextprotocol.io/specification/2025-06-18/server/apps) — the spec extension that lets MCP servers deliver interactive UIs via sandboxed iframes. Extension negotiation, typed UI metadata on tools and resources, and the `ui://` resource scheme. No component DSL, renderer, or `FastMCPApp` class yet — those are future phases. + +**Registering tools with UI metadata:** + +```python +from fastmcp import FastMCP +from fastmcp.server.apps import ToolUI, ResourceUI, ResourceCSP, ResourcePermissions + +mcp = FastMCP("My Server") + +# Register the HTML bundle as a ui:// resource with CSP +@mcp.resource( + "ui://my-app/view.html", + ui=ResourceUI( + csp=ResourceCSP(resource_domains=["https://unpkg.com"]), + permissions=ResourcePermissions(clipboard_write={}), + ), +) +def app_html() -> str: + from pathlib import Path + return Path("./dist/index.html").read_text() + +# Tool with UI — clients render an iframe alongside the result +@mcp.tool(ui=ToolUI(resource_uri="ui://my-app/view.html")) +async def list_users() -> list[dict]: + return [{"id": "1", "name": "Alice"}] + +# App-only tool — visible to the UI but hidden from the model +@mcp.tool(ui=ToolUI(resource_uri="ui://my-app/view.html", visibility=["app"])) +async def delete_user(id: str) -> dict: + return {"deleted": True} +``` + +The `ui=` parameter accepts either a typed model (`ToolUI`, `ResourceUI`) or a raw dict for forward compatibility. It merges into `meta["ui"]` — alongside any other metadata you set. + +**`ui://` resources** automatically get the correct MIME type (`text/html;profile=mcp-app`) unless you override it explicitly. + +**Extension negotiation**: The server advertises `io.modelcontextprotocol/ui` in `capabilities.extensions`. UI metadata (`_meta.ui`) always flows through to clients — the MCP Apps spec assigns visibility enforcement to the host, not the server. Tools can check whether the connected client supports a given extension at runtime via `ctx.client_supports_extension()`: + +```python +from fastmcp import Context +from fastmcp.server.apps import ToolUI, UI_EXTENSION_ID + +@mcp.tool(ui=ToolUI(resource_uri="ui://dashboard")) +async def dashboard(ctx: Context) -> dict: + data = compute_dashboard() + if ctx.client_supports_extension(UI_EXTENSION_ID): + return data + return {"summary": format_text(data)} +``` + +**Key details:** +- `ToolUI` fields: `resource_uri`, `visibility`, `csp`, `permissions`, `domain`, `prefers_border` (all optional except for typical usage of `resource_uri`) +- `ResourceUI` fields: `csp`, `permissions`, `domain`, `prefers_border` — metadata for the resource itself when it's a UI bundle +- `csp` accepts a `ResourceCSP` model with structured domain lists: `connect_domains`, `resource_domains`, `frame_domains`, `base_uri_domains` +- `permissions` accepts a `ResourcePermissions` model: `camera`, `microphone`, `geolocation`, `clipboard_write` (each set to `{}` to request) +- Both models use `extra="allow"` for forward compatibility with future spec additions +- Models use Pydantic aliases for wire format (`resourceUri`, `prefersBorder`, `connectDomains`, `clipboardWrite`) +- Resource metadata (including CSP/permissions) is propagated to `resources/read` response content items so hosts can read it when rendering the iframe +- `ctx.client_supports_extension(id)` is a general-purpose method — works for any extension, not just MCP Apps +- `structuredContent` in tool results already works via `ToolResult` — MCP Apps clients use this to pass data into the iframe +- The server does not strip `_meta.ui` for non-UI clients; per the spec, visibility enforcement is the host's responsibility + +**Future phases** will add a component DSL for building UIs declaratively, an in-repo renderer, and a `FastMCPApp` class. + +Implementation: `src/fastmcp/server/apps.py` (models and constants), with integration points in `server.py` (decorator parameters), `low_level.py` (extension advertisement), and `context.py` (`client_supports_extension` method). + --- ## 3.0.0beta1 @@ -658,70 +727,6 @@ STDIO transport bypasses all auth checks (no OAuth concept). --- -### MCP Apps (SDK Compatibility) - -v3.0 adds Phase 1 support for [MCP Apps](https://modelcontextprotocol.io/specification/2025-06-18/server/apps) — the spec extension that lets MCP servers deliver interactive UIs via sandboxed iframes. Phase 1 is SDK compatibility only: extension negotiation, typed UI metadata on tools and resources, and the `ui://` resource scheme. No component DSL, renderer, or `FastMCPApp` class yet — those are future phases. - -**Registering tools with UI metadata:** - -```python -from fastmcp import FastMCP -from fastmcp.server.apps import ToolUI, ResourceUI, UI_MIME_TYPE - -mcp = FastMCP("My Server") - -# Register the HTML bundle as a ui:// resource -@mcp.resource("ui://my-app/view.html") -def app_html() -> str: - from pathlib import Path - return Path("./dist/index.html").read_text() - -# Tool with UI — clients render an iframe alongside the result -@mcp.tool(ui=ToolUI(resource_uri="ui://my-app/view.html")) -async def list_users() -> list[dict]: - return [{"id": "1", "name": "Alice"}] - -# App-only tool — visible to the UI but hidden from the model -@mcp.tool(ui=ToolUI(resource_uri="ui://my-app/view.html", visibility=["app"])) -async def delete_user(id: str) -> dict: - return {"deleted": True} -``` - -The `ui=` parameter accepts either a typed model (`ToolUI`, `ResourceUI`) or a raw dict for forward compatibility. It merges into `meta["ui"]` — alongside any other metadata you set. - -**`ui://` resources** automatically get the correct MIME type (`text/html;profile=mcp-app`) unless you override it explicitly. - -**Extension negotiation**: The server advertises `io.modelcontextprotocol/ui` in `capabilities.extensions`. UI metadata (`_meta.ui`) always flows through to clients — the MCP Apps spec assigns visibility enforcement to the host, not the server. Tools can check whether the connected client supports a given extension at runtime via `ctx.client_supports_extension()`: - -```python -from fastmcp import Context -from fastmcp.server.apps import ToolUI, UI_EXTENSION_ID - -@mcp.tool(ui=ToolUI(resource_uri="ui://dashboard")) -async def dashboard(ctx: Context) -> dict: - data = compute_dashboard() - if ctx.client_supports_extension(UI_EXTENSION_ID): - # Client will render the iframe with structured data - return data - # Fallback: text-only summary - return {"summary": format_text(data)} -``` - -**Key details:** -- `ToolUI` fields: `resource_uri`, `visibility`, `csp`, `permissions`, `domain`, `prefers_border` (all optional except for typical usage of `resource_uri`) -- `ResourceUI` fields: `csp`, `permissions`, `domain`, `prefers_border` — metadata for the resource itself when it's a UI bundle -- Models use Pydantic aliases for wire format (`resourceUri`, `prefersBorder`) -- `ctx.client_supports_extension(id)` is a general-purpose method — works for any extension, not just MCP Apps -- `structuredContent` in tool results already works via `ToolResult` — MCP Apps clients use this to pass data into the iframe -- Text content fallback already works — tools return both `content` and `structured_content` -- The server does not strip `_meta.ui` for non-UI clients; per the spec, visibility enforcement is the host's responsibility - -**Future phases** will add a component DSL for building UIs declaratively, an in-repo renderer, and a `FastMCPApp` class. - -Implementation: `src/fastmcp/server/apps.py` (models and constants), with integration points in `server.py` (decorator parameters), `low_level.py` (extension advertisement), and `context.py` (`client_supports_extension` method). - ---- - ### FileSystemProvider v3.0 introduces `FileSystemProvider`, a fundamentally different approach to organizing MCP servers. Instead of importing a server instance and decorating functions with `@server.tool`, you use standalone decorators in separate files and let the provider discover them. diff --git a/examples/apps/qr_server/README.md b/examples/apps/qr_server/README.md new file mode 100644 index 0000000000..f2c9ebf1d0 --- /dev/null +++ b/examples/apps/qr_server/README.md @@ -0,0 +1,35 @@ +# QR Code MCP App + +An MCP App server that generates QR codes with an interactive viewer UI. Ported from the [ext-apps QR server example](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server) to demonstrate FastMCP's MCP Apps support. + +## What it demonstrates + +- Linking a tool to a `ui://` resource via `ToolUI` +- Serving embedded HTML with the `@modelcontextprotocol/ext-apps` JS SDK from CDN +- Declaring CSP resource domains via `ResourceCSP` +- Returning `ImageContent` (base64 PNG) from a tool + +## Setup + +```bash +cd examples/apps/qr_server +uv sync +``` + +## Usage + +```bash +uv run python qr_server.py +``` + +Or install it into an MCP client: + +```bash +fastmcp install stdio fastmcp.json +``` + +## How it works + +The server registers one tool (`generate_qr`) and one resource (`ui://qr-server/view.html`). The tool generates a QR code as a base64 PNG image. The resource serves an HTML page that uses the MCP Apps JS SDK to receive the tool result and display the image in a sandboxed iframe. + +The HTML loads the ext-apps SDK from unpkg, so the resource declares `csp=ResourceCSP(resource_domains=["https://unpkg.com"])` to allow the host to set the appropriate Content-Security-Policy. diff --git a/examples/apps/qr_server/fastmcp.json b/examples/apps/qr_server/fastmcp.json new file mode 100644 index 0000000000..7e74faa318 --- /dev/null +++ b/examples/apps/qr_server/fastmcp.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "qr_server.py" + }, + "environment": { + "dependencies": [ + "fastmcp", + "qrcode[pil]>=8.0", + "pillow" + ] + } +} diff --git a/examples/apps/qr_server/pyproject.toml b/examples/apps/qr_server/pyproject.toml new file mode 100644 index 0000000000..815ea0a758 --- /dev/null +++ b/examples/apps/qr_server/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "fastmcp-app-examples" +version = "0.1.0" +description = "MCP App examples for FastMCP" +requires-python = ">=3.10" +dependencies = [ + "fastmcp", + "qrcode[pil]>=8.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/examples/apps/qr_server/qr_server.py b/examples/apps/qr_server/qr_server.py new file mode 100644 index 0000000000..8478cd5579 --- /dev/null +++ b/examples/apps/qr_server/qr_server.py @@ -0,0 +1,170 @@ +"""QR Code MCP App Server — generates QR codes with an interactive view UI. + +Demonstrates MCP Apps with FastMCP: +- Tool linked to a ui:// resource via ToolUI +- HTML resource with CSP metadata for CDN-loaded dependencies +- Embedded HTML using the @modelcontextprotocol/ext-apps JS SDK +- ImageContent return type for binary data +- Both stdio and HTTP transport modes + +Based on https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server + +Setup (from examples/apps/): + uv sync + +Usage: + uv run python qr_server.py # HTTP mode (port 3001) + uv run python qr_server.py --stdio # stdio mode for MCP clients +""" + +from __future__ import annotations + +import base64 +import io + +import qrcode # type: ignore[import-untyped] +from mcp import types + +from fastmcp import FastMCP +from fastmcp.server.apps import ResourceCSP, ResourceUI, ToolUI +from fastmcp.tools import ToolResult + +VIEW_URI: str = "ui://qr-server/view.html" + +mcp: FastMCP = FastMCP("QR Code Server") + +EMBEDDED_VIEW_HTML: str = """\ + + + + + + + +
+ + +""" + + +@mcp.tool(ui=ToolUI(resource_uri=VIEW_URI)) +def generate_qr( + text: str = "https://gofastmcp.com", + box_size: int = 10, + border: int = 4, + error_correction: str = "M", + fill_color: str = "black", + back_color: str = "white", +) -> ToolResult: + """Generate a QR code from text. + + Args: + text: The text/URL to encode + box_size: Size of each box in pixels (default: 10) + border: Border size in boxes (default: 4) + error_correction: Error correction level - L(7%), M(15%), Q(25%), H(30%) + fill_color: Foreground color (hex like #FF0000 or name like red) + back_color: Background color (hex like #FFFFFF or name like white) + """ + error_levels = { + "L": qrcode.constants.ERROR_CORRECT_L, + "M": qrcode.constants.ERROR_CORRECT_M, + "Q": qrcode.constants.ERROR_CORRECT_Q, + "H": qrcode.constants.ERROR_CORRECT_H, + } + + if box_size <= 0: + raise ValueError("box_size must be > 0") + if border < 0: + raise ValueError("border must be >= 0") + + error_key = error_correction.upper() + if error_key not in error_levels: + raise ValueError(f"error_correction must be one of: {', '.join(error_levels)}") + + qr = qrcode.QRCode( + version=1, + error_correction=error_levels[error_key], + box_size=box_size, + border=border, + ) + qr.add_data(text) + qr.make(fit=True) + + img = qr.make_image(fill_color=fill_color, back_color=back_color) + buffer = io.BytesIO() + img.save(buffer, format="PNG") + b64 = base64.b64encode(buffer.getvalue()).decode() + return ToolResult( + content=[types.ImageContent(type="image", data=b64, mimeType="image/png")] + ) + + +@mcp.resource( + VIEW_URI, + ui=ResourceUI(csp=ResourceCSP(resource_domains=["https://unpkg.com"])), +) +def view() -> str: + """Interactive QR code viewer — renders tool results as images.""" + return EMBEDDED_VIEW_HTML + + +if __name__ == "__main__": + mcp.run() diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py index c84e49a259..f88febfd7d 100644 --- a/src/fastmcp/cli/install/shared.py +++ b/src/fastmcp/cli/install/shared.py @@ -83,6 +83,8 @@ async def process_common_args( file = (config_path.parent / source_path).resolve() else: file = source_path.resolve() + # Update the source path so load_server() resolves correctly + config.source.path = str(file) server_object = ( config.source.entrypoint if hasattr(config.source, "entrypoint") else None ) diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 6b23468e4d..2aaca1863e 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -307,12 +307,27 @@ def convert_result(self, raw_value: Any) -> ResourceResult: 2. In tasks_result_handler() to convert Docket task results to ResourceResult Handles ResourceResult passthrough and converts raw values using - ResourceResult's normalization. + ResourceResult's normalization. When the raw value is a plain + string or bytes, the resource's own ``mime_type`` is forwarded so + that ``ui://`` resources (and others with non-default MIME types) + don't fall back to ``text/plain``. + + The resource's component-level ``meta`` (e.g. ``ui`` metadata for + MCP Apps CSP/permissions) is propagated to each content item so + that hosts can read it from the ``resources/read`` response. """ if isinstance(raw_value, ResourceResult): return raw_value - # ResourceResult.__init__ handles all normalization + # For plain str/bytes returns, wrap in ResourceContent with the + # resource's MIME type and component meta so the wire response + # carries the correct type and metadata (e.g. CSP for MCP Apps). + if isinstance(raw_value, (str, bytes)): + return ResourceResult( + [ResourceContent(raw_value, mime_type=self.mime_type, meta=self.meta)] + ) + + # ResourceResult.__init__ handles all other normalization return ResourceResult(raw_value) @overload diff --git a/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py index 2e22511a64..566938b987 100644 --- a/src/fastmcp/server/apps.py +++ b/src/fastmcp/server/apps.py @@ -15,6 +15,65 @@ UI_MIME_TYPE = "text/html;profile=mcp-app" +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, + alias="connectDomains", + description="Origins allowed for fetch/XHR/WebSocket (connect-src)", + ) + resource_domains: list[str] | None = Field( + default=None, + alias="resourceDomains", + description="Origins allowed for scripts, images, styles, fonts (script-src etc.)", + ) + frame_domains: list[str] | None = Field( + default=None, + alias="frameDomains", + description="Origins allowed for nested iframes (frame-src)", + ) + base_uri_domains: list[str] | None = Field( + default=None, + 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, + alias="clipboardWrite", + description="Request clipboard-write access", + ) + + model_config = {"populate_by_name": True, "extra": "allow"} + + class ToolUI(BaseModel): """Typed ``_meta.ui`` for tools — links a tool to its UI resource. @@ -32,9 +91,11 @@ class ToolUI(BaseModel): default=None, description="Where this tool is visible: 'app', 'model', or both", ) - csp: str | None = Field(default=None, description="Content Security Policy") - permissions: list[str] | None = Field( - default=None, description="iframe permissions" + 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( @@ -49,9 +110,11 @@ class ToolUI(BaseModel): class ResourceUI(BaseModel): """Typed ``_meta.ui`` for resources — rendering hints for UI-capable clients.""" - csp: str | None = Field(default=None, description="Content Security Policy") - permissions: list[str] | None = Field( - default=None, description="iframe permissions" + 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( diff --git a/tests/test_apps.py b/tests/test_apps.py index 6081de7a24..348eab36b4 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -12,6 +12,8 @@ from fastmcp.server.apps import ( UI_EXTENSION_ID, UI_MIME_TYPE, + ResourceCSP, + ResourcePermissions, ResourceUI, ToolUI, ui_to_meta_dict, @@ -38,8 +40,8 @@ def test_all_fields(self): ui = ToolUI( resource_uri="ui://app", visibility=["app", "model"], - csp="default-src 'self'", - permissions=["clipboard-read"], + csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), + permissions=ResourcePermissions(camera={}, clipboard_write={}), domain="example.com", prefers_border=True, ) @@ -47,8 +49,8 @@ def test_all_fields(self): assert d == { "resourceUri": "ui://app", "visibility": ["app", "model"], - "csp": "default-src 'self'", - "permissions": ["clipboard-read"], + "csp": {"resourceDomains": ["https://cdn.example.com"]}, + "permissions": {"camera": {}, "clipboardWrite": {}}, "domain": "example.com", "prefersBorder": True, } @@ -58,17 +60,124 @@ def test_populate_by_name(self): assert ui.resource_uri == "ui://app" +class TestResourceCSP: + def test_serializes_with_aliases(self): + csp = ResourceCSP( + connect_domains=["https://api.example.com"], + resource_domains=["https://cdn.example.com"], + ) + d = csp.model_dump(by_alias=True, exclude_none=True) + assert d == { + "connectDomains": ["https://api.example.com"], + "resourceDomains": ["https://cdn.example.com"], + } + + def test_excludes_none_fields(self): + csp = ResourceCSP(resource_domains=["https://unpkg.com"]) + d = csp.model_dump(by_alias=True, exclude_none=True) + assert d == {"resourceDomains": ["https://unpkg.com"]} + + def test_all_fields(self): + csp = ResourceCSP( + connect_domains=["https://api.example.com"], + resource_domains=["https://cdn.example.com"], + frame_domains=["https://embed.example.com"], + base_uri_domains=["https://base.example.com"], + ) + d = csp.model_dump(by_alias=True, exclude_none=True) + assert d == { + "connectDomains": ["https://api.example.com"], + "resourceDomains": ["https://cdn.example.com"], + "frameDomains": ["https://embed.example.com"], + "baseUriDomains": ["https://base.example.com"], + } + + def test_populate_by_name(self): + csp = ResourceCSP(connect_domains=["https://api.example.com"]) + assert csp.connect_domains == ["https://api.example.com"] + + def test_empty(self): + csp = ResourceCSP() + d = csp.model_dump(by_alias=True, exclude_none=True) + assert d == {} + + def test_extra_fields_preserved(self): + """Unknown CSP directives from future spec versions pass through.""" + csp = ResourceCSP( + resource_domains=["https://cdn.example.com"], + **{"workerDomains": ["https://worker.example.com"]}, + ) + d = csp.model_dump(by_alias=True, exclude_none=True) + assert d["resourceDomains"] == ["https://cdn.example.com"] + assert d["workerDomains"] == ["https://worker.example.com"] + + +class TestResourcePermissions: + def test_serializes_with_aliases(self): + perms = ResourcePermissions(microphone={}, clipboard_write={}) + d = perms.model_dump(by_alias=True, exclude_none=True) + assert d == {"microphone": {}, "clipboardWrite": {}} + + def test_excludes_none_fields(self): + perms = ResourcePermissions(camera={}) + d = perms.model_dump(by_alias=True, exclude_none=True) + assert d == {"camera": {}} + + def test_all_fields(self): + perms = ResourcePermissions( + camera={}, microphone={}, geolocation={}, clipboard_write={} + ) + d = perms.model_dump(by_alias=True, exclude_none=True) + assert d == { + "camera": {}, + "microphone": {}, + "geolocation": {}, + "clipboardWrite": {}, + } + + def test_populate_by_name(self): + perms = ResourcePermissions(clipboard_write={}) + assert perms.clipboard_write == {} + + def test_extra_fields_preserved(self): + """Unknown permissions from future spec versions pass through.""" + perms = ResourcePermissions(camera={}, **{"midi": {}}) + d = perms.model_dump(by_alias=True, exclude_none=True) + assert d["camera"] == {} + assert d["midi"] == {} + + def test_empty(self): + perms = ResourcePermissions() + d = perms.model_dump(by_alias=True, exclude_none=True) + assert d == {} + + class TestResourceUI: def test_serializes_with_aliases(self): - ui = ResourceUI(prefers_border=True, csp="default-src 'self'") + ui = ResourceUI( + prefers_border=True, + csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), + ) d = ui.model_dump(by_alias=True, exclude_none=True) - assert d == {"prefersBorder": True, "csp": "default-src 'self'"} + assert d == { + "prefersBorder": True, + "csp": {"resourceDomains": ["https://cdn.example.com"]}, + } def test_excludes_none_fields(self): ui = ResourceUI() d = ui.model_dump(by_alias=True, exclude_none=True) assert d == {} + def test_with_permissions(self): + ui = ResourceUI( + permissions=ResourcePermissions(microphone={}, clipboard_write={}), + ) + d = ui.model_dump(by_alias=True, exclude_none=True) + assert d == { + "permissions": {"microphone": {}, "clipboardWrite": {}}, + } + class TestUIToMetaDict: def test_from_tool_ui(self): @@ -303,6 +412,19 @@ def app_html() -> str: assert str(resources[0].uri) == "ui://my-app/view.html" assert resources[0].mimeType == UI_MIME_TYPE + async def test_ui_resource_read_preserves_mime_type(self): + """Reading a ui:// resource returns content with the correct MIME type.""" + server = FastMCP("test") + + @server.resource("ui://my-app/view.html") + def app_html() -> str: + return "Hello" + + async with Client(server) as client: + result = await client.read_resource_mcp("ui://my-app/view.html") + assert len(result.contents) == 1 + assert result.contents[0].mimeType == UI_MIME_TYPE + async def test_ui_tool_callable(self): """A tool registered with ui= is still callable normally.""" server = FastMCP("test") @@ -332,3 +454,72 @@ def dashboard() -> str: async with Client(server) as client: extras = client.initialize_result.capabilities.model_extra or {} assert UI_EXTENSION_ID in extras.get("extensions", {}) + + async def test_csp_and_permissions_roundtrip(self): + """CSP and permissions metadata flows through to clients correctly.""" + server = FastMCP("test") + + @server.resource( + "ui://secure-app/view.html", + ui=ResourceUI( + csp=ResourceCSP( + resource_domains=["https://unpkg.com"], + connect_domains=["https://api.example.com"], + ), + permissions=ResourcePermissions(microphone={}, clipboard_write={}), + ), + ) + def secure_app() -> str: + return "secure" + + @server.tool( + ui=ToolUI( + resource_uri="ui://secure-app/view.html", + csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), + permissions=ResourcePermissions(camera={}), + ) + ) + def secure_tool() -> str: + return "result" + + async with Client(server) as client: + # Verify resource metadata + resources = await client.list_resources() + assert len(resources) == 1 + meta = resources[0].meta + assert meta is not None + assert meta["ui"]["csp"]["resourceDomains"] == ["https://unpkg.com"] + assert meta["ui"]["csp"]["connectDomains"] == ["https://api.example.com"] + assert meta["ui"]["permissions"]["microphone"] == {} + assert meta["ui"]["permissions"]["clipboardWrite"] == {} + + # Verify tool metadata + tools = await client.list_tools() + assert len(tools) == 1 + tool_meta = tools[0].meta + assert tool_meta is not None + assert tool_meta["ui"]["csp"]["resourceDomains"] == [ + "https://cdn.example.com" + ] + assert tool_meta["ui"]["permissions"]["camera"] == {} + + async def test_resource_read_propagates_meta_to_content_items(self): + """resources/read must include _meta on content items so hosts can read CSP.""" + server = FastMCP("test") + + @server.resource( + "ui://csp-app/view.html", + ui=ResourceUI( + csp=ResourceCSP(resource_domains=["https://unpkg.com"]), + ), + ) + def app_view() -> str: + return "app" + + async with Client(server) as client: + read_result = await client.read_resource_mcp("ui://csp-app/view.html") + content_item = read_result.contents[0] + assert content_item.meta is not None + assert content_item.meta["ui"]["csp"]["resourceDomains"] == [ + "https://unpkg.com" + ]