mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 13:04:18 +02:00
MCP Apps: structured CSP/permissions types, resource meta propagation fix, QR example (#3031)
This commit is contained in:
parent
2629631536
commit
12cb58f220
9 changed files with 585 additions and 78 deletions
|
|
@ -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.
|
||||
|
|
|
|||
35
examples/apps/qr_server/README.md
Normal file
35
examples/apps/qr_server/README.md
Normal 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.
|
||||
13
examples/apps/qr_server/fastmcp.json
Normal file
13
examples/apps/qr_server/fastmcp.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
examples/apps/qr_server/pyproject.toml
Normal file
13
examples/apps/qr_server/pyproject.toml
Normal 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"
|
||||
170
examples/apps/qr_server/qr_server.py
Normal file
170
examples/apps/qr_server/qr_server.py
Normal 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()
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue