MCP Apps: structured CSP/permissions types, resource meta propagation fix, QR example (#3031)

This commit is contained in:
Jeremiah Lowin 2026-01-30 14:05:25 -05:00 committed by GitHub
commit 12cb58f220
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 585 additions and 78 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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"
]
}
}

View file

@ -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"

View file

@ -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 = """\
<!DOCTYPE html>
<html>
<head>
<meta name="color-scheme" content="light dark">
<style>
html, body {
margin: 0;
padding: 0;
overflow: hidden;
background: transparent;
}
body {
display: flex;
justify-content: center;
align-items: center;
height: 340px;
width: 340px;
}
img {
width: 300px;
height: 300px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div id="qr"></div>
<script type="module">
import { App } from "https://unpkg.com/@modelcontextprotocol/ext-apps@0.4.0/app-with-deps";
const app = new App({ name: "QR View", version: "1.0.0" });
app.ontoolresult = ({ content }) => {
const img = content?.find(c => c.type === 'image');
if (img) {
const qrDiv = document.getElementById('qr');
qrDiv.innerHTML = '';
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif'];
const mimeType = allowedTypes.includes(img.mimeType) ? img.mimeType : 'image/png';
const image = document.createElement('img');
image.src = `data:${mimeType};base64,${img.data}`;
image.alt = "QR Code";
qrDiv.appendChild(image);
}
};
function handleHostContextChanged(ctx) {
if (ctx.safeAreaInsets) {
document.body.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
document.body.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
document.body.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
document.body.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
}
}
app.onhostcontextchanged = handleHostContextChanged;
await app.connect();
const ctx = app.getHostContext();
if (ctx) {
handleHostContextChanged(ctx);
}
</script>
</body>
</html>"""
@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()

View file

@ -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
)

View file

@ -307,12 +307,27 @@ class Resource(FastMCPComponent):
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

View file

@ -15,6 +15,65 @@ UI_EXTENSION_ID = "io.modelcontextprotocol/ui"
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(

View file

@ -12,6 +12,8 @@ from fastmcp import Client, FastMCP
from fastmcp.server.apps import (
UI_EXTENSION_ID,
UI_MIME_TYPE,
ResourceCSP,
ResourcePermissions,
ResourceUI,
ToolUI,
ui_to_meta_dict,
@ -38,8 +40,8 @@ class TestToolUI:
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 @@ class TestToolUI:
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 @@ class TestToolUI:
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 @@ class TestIntegration:
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 "<html><body>Hello</body></html>"
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 @@ class TestIntegration:
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 "<html>secure</html>"
@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 "<html>app</html>"
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"
]