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
64 changes: 64 additions & 0 deletions docs/development/v3-notes/v3-features.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,70 @@ 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()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 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.
Expand Down
6 changes: 5 additions & 1 deletion src/fastmcp/resources/function_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import fastmcp
from fastmcp.decorators import resolve_task_config
from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.server.apps import resolve_ui_mime_type
from fastmcp.server.dependencies import (
transform_context_annotations,
without_injected_parameters,
Expand Down Expand Up @@ -180,6 +181,9 @@ def from_function(
# Wrap fn to handle dependency resolution internally
wrapped_fn = without_injected_parameters(fn)

# Apply ui:// MIME default, then fall back to text/plain
resolved_mime = resolve_ui_mime_type(metadata.uri, metadata.mime_type)

return cls(
fn=wrapped_fn,
uri=uri_obj,
Expand All @@ -188,7 +192,7 @@ def from_function(
title=metadata.title,
description=metadata.description or inspect.getdoc(fn),
icons=metadata.icons,
mime_type=metadata.mime_type or "text/plain",
mime_type=resolved_mime or "text/plain",
tags=metadata.tags or set(),
annotations=metadata.annotations,
meta=metadata.meta,
Expand Down
6 changes: 5 additions & 1 deletion src/fastmcp/resources/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
)

from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.server.apps import resolve_ui_mime_type
from fastmcp.server.dependencies import (
transform_context_annotations,
without_injected_parameters,
Expand Down Expand Up @@ -567,14 +568,17 @@ def from_function(
# Use validate_call on wrapper for runtime type coercion
fn = validate_call(wrapper_fn)

# Apply ui:// MIME default, then fall back to text/plain
resolved_mime = resolve_ui_mime_type(uri_template, mime_type)

return cls(
uri_template=uri_template,
name=func_name,
version=str(version) if version is not None else None,
title=title,
description=description,
icons=icons,
mime_type=mime_type or "text/plain",
mime_type=resolved_mime or "text/plain",
fn=fn,
parameters=parameters,
tags=tags or set(),
Expand Down
93 changes: 93 additions & 0 deletions src/fastmcp/server/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""MCP Apps support — extension negotiation and typed UI metadata models.

Provides constants and Pydantic models for the MCP Apps extension
(io.modelcontextprotocol/ui), enabling tools and resources to carry
UI metadata for clients that support interactive app rendering.
"""

from __future__ import annotations

from typing import Any

from pydantic import BaseModel, Field

UI_EXTENSION_ID = "io.modelcontextprotocol/ui"
UI_MIME_TYPE = "text/html;profile=mcp-app"


class ToolUI(BaseModel):
"""Typed ``_meta.ui`` for tools — links a tool to its UI resource.

All fields use ``exclude_none`` serialization so only explicitly-set
values appear on the wire. Aliases match the MCP Apps wire format
(camelCase).
"""

resource_uri: str | None = Field(
default=None,
alias="resourceUri",
description="URI of the UI resource (typically ui:// scheme)",
)
visibility: list[str] | None = Field(
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"
)
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",
)

model_config = {"populate_by_name": True}


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"
)
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",
)

model_config = {"populate_by_name": True}


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 resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None:
"""Return the appropriate MIME type for a resource URI.

For ``ui://`` scheme resources, defaults to ``UI_MIME_TYPE`` when no
explicit MIME type is provided. This ensures UI resources are correctly
identified regardless of how they're registered (via FastMCP.resource,
the standalone @resource decorator, or resource templates).

Args:
uri: The resource URI string
explicit_mime_type: The MIME type explicitly provided by the user

Returns:
The resolved MIME type (explicit value, UI default, or None)
"""
if explicit_mime_type is not None:
return explicit_mime_type
# Case-insensitive scheme check per RFC 3986
if uri.lower().startswith("ui://"):
return UI_MIME_TYPE
return None
28 changes: 28 additions & 0 deletions src/fastmcp/server/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
handle_elicit_accept,
parse_elicit_response_type,
)
from fastmcp.server.low_level import MiddlewareServerSession
from fastmcp.server.sampling import SampleStep, SamplingResult, SamplingTool
from fastmcp.server.sampling.run import (
sample_impl,
Expand Down Expand Up @@ -455,6 +456,33 @@ def transport(self) -> TransportType | None:
"""
return _current_transport.get()

def client_supports_extension(self, extension_id: str) -> bool:
"""Check whether the connected client supports a given MCP extension.

Inspects the ``extensions`` extra field on ``ClientCapabilities``
sent by the client during initialization.

Returns ``False`` when no session is available (e.g., outside a
request context) or when the client did not advertise the extension.

Example::

from fastmcp.server.apps import UI_EXTENSION_ID

@mcp.tool
async def my_tool(ctx: Context) -> str:
if ctx.client_supports_extension(UI_EXTENSION_ID):
return "UI-capable client"
return "text-only client"
"""
rc = self.request_context
if rc is None:
return False
session = rc.session
if not isinstance(session, MiddlewareServerSession):
return False
return session.client_supports_extension(extension_id)

@property
def client_id(self) -> str | None:
"""Get the client ID if available."""
Expand Down
29 changes: 29 additions & 0 deletions src/fastmcp/server/low_level.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from mcp.shared.session import RequestResponder
from pydantic import AnyUrl

from fastmcp.server.apps import UI_EXTENSION_ID
from fastmcp.utilities.logging import get_logger

if TYPE_CHECKING:
Expand All @@ -49,6 +50,25 @@ def fastmcp(self) -> FastMCP:
raise RuntimeError("FastMCP instance is no longer available")
return fastmcp

def client_supports_extension(self, extension_id: str) -> bool:
"""Check if the connected client supports a given MCP extension.

Inspects the ``extensions`` extra field on ``ClientCapabilities``
sent by the client during initialization.
"""
client_params = self._client_params
if client_params is None:
return False
caps = client_params.capabilities
if caps is None:
return False
# ClientCapabilities uses extra="allow" — extensions is an extra field
extras = caps.model_extra or {}
extensions: dict[str, Any] | None = extras.get("extensions")
if not extensions:
return False
return extension_id in extensions

async def _received_request(
self,
responder: RequestResponder[mcp.types.ClientRequest, mcp.types.ServerResult],
Expand Down Expand Up @@ -188,6 +208,15 @@ def get_capabilities(
# Set tasks as a first-class field (not experimental) per SEP-1686
capabilities.tasks = get_task_capabilities()

# Advertise MCP Apps extension support (io.modelcontextprotocol/ui)
# Uses the same extra-field pattern as tasks above — ServerCapabilities
# has extra="allow" so this survives serialization.
# Merge with any existing extensions to avoid clobbering other features.
existing_extensions: dict[str, Any] = (
getattr(capabilities, "extensions", None) or {}
)
capabilities.extensions = {**existing_extensions, UI_EXTENSION_ID: {}}

return capabilities

async def run(
Expand Down
2 changes: 2 additions & 0 deletions src/fastmcp/server/mixins/mcp_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ async def _list_tools_mcp(

tools = _dedupe_with_versions(list(await server.list_tools()), lambda t: t.name)
sdk_tools = [tool.to_mcp_tool(name=tool.name) for tool in tools]

# SDK may pass None for internal cache refresh despite type hint
cursor = (
request.params.cursor if request is not None and request.params else None
Expand All @@ -177,6 +178,7 @@ async def _list_resources_mcp(
sdk_resources = [
resource.to_mcp_resource(uri=str(resource.uri)) for resource in resources
]

cursor = request.params.cursor if request.params else None
page, next_cursor = _apply_pagination(
sdk_resources, cursor, server._list_page_size
Expand Down
Loading