Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions docs/development/v3-notes/v3-features.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -203,18 +203,20 @@ The `require_auth` authorization check introduced in beta1 has been removed in f

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:**
**Breaking change from beta 2:** The `ui=` parameter on `@mcp.tool()` and `@mcp.resource()` has been renamed to `app=`, and the `ToolUI`/`ResourceUI` classes have been consolidated into a single `AppConfig` class. This follows the established `task=True`/`TaskConfig` pattern. The wire format (`meta["ui"]`, `_meta.ui`) is unchanged.

**Registering tools with app metadata:**

```python
from fastmcp import FastMCP
from fastmcp.server.apps import ToolUI, ResourceUI, ResourceCSP, ResourcePermissions
from fastmcp.server.apps import AppConfig, 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(
app=AppConfig(
csp=ResourceCSP(resource_domains=["https://unpkg.com"]),
permissions=ResourcePermissions(clipboard_write={}),
),
Expand All @@ -224,27 +226,27 @@ def app_html() -> str:
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"))
@mcp.tool(app=AppConfig(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"]))
@mcp.tool(app=AppConfig(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.
The `app=` parameter accepts `True` (enable with defaults), an `AppConfig` instance, 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
from fastmcp.server.apps import AppConfig, UI_EXTENSION_ID

@mcp.tool(ui=ToolUI(resource_uri="ui://dashboard"))
@mcp.tool(app=AppConfig(resource_uri="ui://dashboard"))
async def dashboard(ctx: Context) -> dict:
data = compute_dashboard()
if ctx.client_supports_extension(UI_EXTENSION_ID):
Expand All @@ -253,11 +255,10 @@ async def dashboard(ctx: Context) -> dict:
```

**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
- `AppConfig` fields: `resource_uri`, `visibility`, `csp`, `permissions`, `domain`, `prefers_border` (all optional). On resources, `resource_uri` and `visibility` are validated as not-applicable and will raise `ValueError` if set.
- `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
- `AppConfig` uses `extra="allow"` for forward compatibility with future spec additions
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 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
Expand Down
2 changes: 1 addition & 1 deletion examples/apps/qr_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ An MCP App server that generates QR codes with an interactive viewer UI. Ported

## What it demonstrates

- Linking a tool to a `ui://` resource via `ToolUI`
- Linking a tool to a `ui://` resource via `AppConfig`
- 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
Expand Down
8 changes: 4 additions & 4 deletions examples/apps/qr_server/qr_server.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""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
- Tool linked to a ui:// resource via AppConfig
- HTML resource with CSP metadata for CDN-loaded dependencies
- Embedded HTML using the @modelcontextprotocol/ext-apps JS SDK
- ImageContent return type for binary data
Expand All @@ -26,7 +26,7 @@
from mcp import types

from fastmcp import FastMCP
from fastmcp.server.apps import ResourceCSP, ResourceUI, ToolUI
from fastmcp.server.apps import AppConfig, ResourceCSP
from fastmcp.tools import ToolResult

VIEW_URI: str = "ui://qr-server/view.html"
Expand Down Expand Up @@ -104,7 +104,7 @@
</html>"""


@mcp.tool(ui=ToolUI(resource_uri=VIEW_URI))
@mcp.tool(app=AppConfig(resource_uri=VIEW_URI))
def generate_qr(
text: str = "https://gofastmcp.com",
box_size: int = 10,
Expand Down Expand Up @@ -159,7 +159,7 @@ def generate_qr(

@mcp.resource(
VIEW_URI,
ui=ResourceUI(csp=ResourceCSP(resource_domains=["https://unpkg.com"])),
app=AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"])),
)
def view() -> str:
"""Interactive QR code viewer — renders tool results as images."""
Expand Down
44 changes: 15 additions & 29 deletions src/fastmcp/server/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,13 @@ class ResourcePermissions(BaseModel):
model_config = {"populate_by_name": True, "extra": "allow"}


class ToolUI(BaseModel):
"""Typed ``_meta.ui`` for tools — links a tool to its UI resource.
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
Expand All @@ -85,31 +90,12 @@ class ToolUI(BaseModel):
resource_uri: str | None = Field(
default=None,
alias="resourceUri",
description="URI of the UI resource (typically ui:// scheme)",
description="URI of the UI resource (typically ui:// scheme). Tools only.",
)
visibility: list[str] | None = Field(
default=None,
description="Where this tool is visible: 'app', 'model', or both",
)
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,
alias="prefersBorder",
description="Whether the UI prefers a visible border",
description="Where this tool is visible: 'app', 'model', or both. Tools only.",
)

model_config = {"populate_by_name": True}


class ResourceUI(BaseModel):
"""Typed ``_meta.ui`` for resources — rendering hints for UI-capable clients."""

csp: ResourceCSP | None = Field(
default=None, description="Content Security Policy for the app iframe"
)
Expand All @@ -123,14 +109,14 @@ class ResourceUI(BaseModel):
description="Whether the UI prefers a visible border",
)

model_config = {"populate_by_name": True}
model_config = {"populate_by_name": True, "extra": "allow"}


def ui_to_meta_dict(ui: ToolUI | ResourceUI | dict[str, Any]) -> dict[str, Any]:
"""Convert a UI model or dict to the wire-format dict for ``meta["ui"]``."""
if isinstance(ui, (ToolUI, ResourceUI)):
return ui.model_dump(by_alias=True, exclude_none=True)
return ui
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


def resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None:
Expand Down
46 changes: 29 additions & 17 deletions src/fastmcp/server/providers/local_provider/decorators/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,26 +46,38 @@ def add_tool(self: LocalProvider, tool: Tool | Callable[..., Any]) -> Tool:
from fastmcp.decorators import get_fastmcp_meta
from fastmcp.tools.function_tool import ToolMeta

meta = get_fastmcp_meta(tool)
if meta is not None and isinstance(meta, ToolMeta):
resolved_task = meta.task if meta.task is not None else False
enabled = meta.enabled
fmeta = get_fastmcp_meta(tool)
if fmeta is not None and isinstance(fmeta, ToolMeta):
resolved_task = fmeta.task if fmeta.task is not None else False
enabled = fmeta.enabled

# Merge ToolMeta.app into the meta dict
tool_meta = fmeta.meta
if fmeta.app is not None:
from fastmcp.server.apps import app_config_to_meta_dict

tool_meta = dict(tool_meta) if tool_meta else {}
if fmeta.app is True:
tool_meta["ui"] = True
else:
tool_meta["ui"] = app_config_to_meta_dict(fmeta.app)

tool = Tool.from_function(
tool,
name=meta.name,
version=meta.version,
title=meta.title,
description=meta.description,
icons=meta.icons,
tags=meta.tags,
output_schema=meta.output_schema,
annotations=meta.annotations,
meta=meta.meta,
name=fmeta.name,
version=fmeta.version,
title=fmeta.title,
description=fmeta.description,
icons=fmeta.icons,
tags=fmeta.tags,
output_schema=fmeta.output_schema,
annotations=fmeta.annotations,
meta=tool_meta,
task=resolved_task,
exclude_args=meta.exclude_args,
serializer=meta.serializer,
timeout=meta.timeout,
auth=meta.auth,
exclude_args=fmeta.exclude_args,
serializer=fmeta.serializer,
timeout=fmeta.timeout,
auth=fmeta.auth,
)
else:
tool = Tool.from_function(tool)
Expand Down
45 changes: 32 additions & 13 deletions src/fastmcp/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,9 @@
from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.apps import (
ResourceUI,
ToolUI,
AppConfig,
app_config_to_meta_dict,
resolve_ui_mime_type,
ui_to_meta_dict,
)
from fastmcp.server.auth import AuthContext, AuthProvider, run_auth_checks
from fastmcp.server.dependencies import get_access_token
Expand Down Expand Up @@ -1410,7 +1409,7 @@ def tool(
annotations: ToolAnnotations | dict[str, Any] | None = None,
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
ui: ToolUI | dict[str, Any] | None = None,
app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
Expand All @@ -1431,7 +1430,7 @@ def tool(
annotations: ToolAnnotations | dict[str, Any] | None = None,
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
ui: ToolUI | dict[str, Any] | None = None,
app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
Expand All @@ -1451,7 +1450,7 @@ def tool(
annotations: ToolAnnotations | dict[str, Any] | None = None,
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
ui: ToolUI | dict[str, Any] | None = None,
app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
Expand Down Expand Up @@ -1508,10 +1507,13 @@ def my_tool(x: int) -> str:
server.tool(my_function, name="custom_name")
```
"""
# Merge UI metadata into meta["ui"] before passing to provider
if ui is not None:
# Merge app config into meta["ui"] (wire format) before passing to provider
if app is not None and app is not False:
meta = dict(meta) if meta else {}
meta["ui"] = ui_to_meta_dict(ui)
if app is True:
meta["ui"] = True
else:
meta["ui"] = app_config_to_meta_dict(app)

# Delegate to LocalProvider with server-level defaults
result = self._local_provider.tool(
Expand Down Expand Up @@ -1571,7 +1573,7 @@ def resource(
tags: set[str] | None = None,
annotations: Annotations | dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
ui: ResourceUI | dict[str, Any] | None = None,
app: AppConfig | dict[str, Any] | bool | None = None,
task: bool | TaskConfig | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction]:
Expand Down Expand Up @@ -1637,10 +1639,27 @@ async def get_weather(city: str) -> str:
# Apply default MIME type for ui:// scheme resources
mime_type = resolve_ui_mime_type(uri, mime_type)

# Merge UI metadata into meta["ui"] before passing to provider
if ui is not None:
# Validate app config for resources — resource_uri and visibility
# don't apply since the resource itself is the UI
if isinstance(app, AppConfig):
if app.resource_uri is not None:
raise ValueError(
"resource_uri cannot be set on resources — "
"the resource itself is the UI. "
"Use resource_uri on tools to point to a UI resource."
)
if app.visibility is not None:
raise ValueError(
"visibility cannot be set on resources — it only applies to tools."
)

# Merge app config into meta["ui"] (wire format) before passing to provider
if app is not None and app is not False:
meta = dict(meta) if meta else {}
meta["ui"] = ui_to_meta_dict(ui)
if app is True:
meta["ui"] = True
else:
meta["ui"] = app_config_to_meta_dict(app)

# Delegate to LocalProvider with server-level defaults
inner_decorator = self._local_provider.resource(
Expand Down
1 change: 1 addition & 0 deletions src/fastmcp/tools/function_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class ToolMeta:
output_schema: dict[str, Any] | NotSetT | None = NotSet
annotations: ToolAnnotations | None = None
meta: dict[str, Any] | None = None
app: Any = None
task: bool | TaskConfig | None = None
exclude_args: list[str] | None = None
serializer: Any | None = None
Expand Down
Loading