From 131e2e54ff971e4b25916e0ae9a2aef4249e1618 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:44:49 -0500 Subject: [PATCH 1/6] =?UTF-8?q?Add=20MCP=20Apps=20Phase=201=20=E2=80=94=20?= =?UTF-8?q?SDK=20compatibility=20(SEP-1865)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development/v3-notes/v3-features.mdx | 63 ++++ src/fastmcp/server/apps.py | 70 +++++ src/fastmcp/server/context.py | 28 ++ src/fastmcp/server/low_level.py | 29 ++ src/fastmcp/server/mixins/mcp_operations.py | 2 + src/fastmcp/server/server.py | 19 ++ tests/test_apps.py | 307 ++++++++++++++++++++ 7 files changed, 518 insertions(+) create mode 100644 src/fastmcp/server/apps.py create mode 100644 tests/test_apps.py diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 5bd8676a34..95bc1e6bef 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -634,6 +634,69 @@ 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: + return open("./dist/index.html").read() + +# 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/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py new file mode 100644 index 0000000000..de1281f8e4 --- /dev/null +++ b/src/fastmcp/server/apps.py @@ -0,0 +1,70 @@ +"""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 diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 0fe505b16b..9a5eb30876 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -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, @@ -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.""" diff --git a/src/fastmcp/server/low_level.py b/src/fastmcp/server/low_level.py index a51d0b8bfd..2bc004ff32 100644 --- a/src/fastmcp/server/low_level.py +++ b/src/fastmcp/server/low_level.py @@ -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: @@ -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], @@ -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( diff --git a/src/fastmcp/server/mixins/mcp_operations.py b/src/fastmcp/server/mixins/mcp_operations.py index e39d73ec71..6ec6f43ad1 100644 --- a/src/fastmcp/server/mixins/mcp_operations.py +++ b/src/fastmcp/server/mixins/mcp_operations.py @@ -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 @@ -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 diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 48a2fdf933..d1ca8539d8 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -58,6 +58,7 @@ from fastmcp.prompts.prompt import PromptResult from fastmcp.resources.resource import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.apps import UI_MIME_TYPE, ResourceUI, ToolUI, ui_to_meta_dict from fastmcp.server.auth import AuthContext, AuthProvider, run_auth_checks from fastmcp.server.dependencies import get_access_token from fastmcp.server.lifespan import Lifespan @@ -1370,6 +1371,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, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, @@ -1390,6 +1392,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, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, @@ -1409,6 +1412,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, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, @@ -1465,6 +1469,11 @@ 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: + meta = dict(meta) if meta else {} + meta["ui"] = ui_to_meta_dict(ui) + # Delegate to LocalProvider with server-level defaults result = self._local_provider.tool( name_or_fn, @@ -1523,6 +1532,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, task: bool | TaskConfig | None = None, auth: AuthCheckCallable | list[AuthCheckCallable] | None = None, ) -> Callable[[AnyFunction], Resource | ResourceTemplate | AnyFunction]: @@ -1577,6 +1587,15 @@ async def get_weather(city: str) -> str: return f"Weather for {city}: {data}" ``` """ + # Default MIME type for ui:// scheme resources + if mime_type is None and isinstance(uri, str) and uri.startswith("ui://"): + mime_type = UI_MIME_TYPE + + # Merge UI metadata into meta["ui"] before passing to provider + if ui is not None: + meta = dict(meta) if meta else {} + meta["ui"] = ui_to_meta_dict(ui) + # Delegate to LocalProvider with server-level defaults inner_decorator = self._local_provider.resource( uri, diff --git a/tests/test_apps.py b/tests/test_apps.py new file mode 100644 index 0000000000..5e6be84a35 --- /dev/null +++ b/tests/test_apps.py @@ -0,0 +1,307 @@ +"""Tests for MCP Apps Phase 1 — SDK compatibility. + +Covers UI metadata models, tool/resource registration with ``ui=``, +extension negotiation, and the ``Context.client_supports_extension`` method. +""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import Client, FastMCP +from fastmcp.server.apps import ( + UI_EXTENSION_ID, + UI_MIME_TYPE, + ResourceUI, + ToolUI, + ui_to_meta_dict, +) +from fastmcp.server.context import Context + +# --------------------------------------------------------------------------- +# Model serialization +# --------------------------------------------------------------------------- + + +class TestToolUI: + def test_serializes_with_aliases(self): + ui = ToolUI(resource_uri="ui://my-app/view.html", visibility=["app"]) + d = ui.model_dump(by_alias=True, exclude_none=True) + assert d == {"resourceUri": "ui://my-app/view.html", "visibility": ["app"]} + + def test_excludes_none_fields(self): + ui = ToolUI(resource_uri="ui://foo") + d = ui.model_dump(by_alias=True, exclude_none=True) + assert d == {"resourceUri": "ui://foo"} + + def test_all_fields(self): + ui = ToolUI( + resource_uri="ui://app", + visibility=["app", "model"], + csp="default-src 'self'", + permissions=["clipboard-read"], + domain="example.com", + prefers_border=True, + ) + d = ui.model_dump(by_alias=True, exclude_none=True) + assert d == { + "resourceUri": "ui://app", + "visibility": ["app", "model"], + "csp": "default-src 'self'", + "permissions": ["clipboard-read"], + "domain": "example.com", + "prefersBorder": True, + } + + def test_populate_by_name(self): + ui = ToolUI(resource_uri="ui://app") + assert ui.resource_uri == "ui://app" + + +class TestResourceUI: + def test_serializes_with_aliases(self): + ui = ResourceUI(prefers_border=True, csp="default-src 'self'") + d = ui.model_dump(by_alias=True, exclude_none=True) + assert d == {"prefersBorder": True, "csp": "default-src 'self'"} + + def test_excludes_none_fields(self): + ui = ResourceUI() + d = ui.model_dump(by_alias=True, exclude_none=True) + assert d == {} + + +class TestUIToMetaDict: + def test_from_tool_ui(self): + ui = ToolUI(resource_uri="ui://app", visibility=["app"]) + result = ui_to_meta_dict(ui) + assert result["resourceUri"] == "ui://app" + assert result["visibility"] == ["app"] + + def test_from_resource_ui(self): + ui = ResourceUI(prefers_border=False) + result = ui_to_meta_dict(ui) + assert result == {"prefersBorder": False} + + def test_passthrough_for_dict(self): + raw: dict[str, Any] = {"resourceUri": "ui://app", "custom": "value"} + result = ui_to_meta_dict(raw) + assert result is raw + + +# --------------------------------------------------------------------------- +# Tool registration with ui= +# --------------------------------------------------------------------------- + + +class TestToolRegistrationWithUI: + async def test_tool_ui_model(self): + server = FastMCP("test") + + @server.tool(ui=ToolUI(resource_uri="ui://my-app/view.html")) + def my_tool() -> str: + return "hello" + + tools = list(await server.list_tools()) + assert len(tools) == 1 + assert tools[0].meta is not None + assert tools[0].meta["ui"]["resourceUri"] == "ui://my-app/view.html" + + async def test_tool_ui_dict(self): + server = FastMCP("test") + + @server.tool(ui={"resourceUri": "ui://foo", "visibility": ["app"]}) + def my_tool() -> str: + return "hello" + + tools = list(await server.list_tools()) + assert tools[0].meta is not None + assert tools[0].meta["ui"]["resourceUri"] == "ui://foo" + assert tools[0].meta["ui"]["visibility"] == ["app"] + + async def test_ui_merges_with_existing_meta(self): + server = FastMCP("test") + + @server.tool(meta={"custom": "data"}, ui=ToolUI(resource_uri="ui://app")) + def my_tool() -> str: + return "hello" + + tools = list(await server.list_tools()) + meta = tools[0].meta + assert meta is not None + assert meta["custom"] == "data" + assert meta["ui"]["resourceUri"] == "ui://app" + + async def test_ui_in_mcp_wire_format(self): + server = FastMCP("test") + + @server.tool(ui=ToolUI(resource_uri="ui://app", visibility=["app"])) + def my_tool() -> str: + return "hello" + + tools = list(await server.list_tools()) + mcp_tool = tools[0].to_mcp_tool() + assert mcp_tool.meta is not None + assert mcp_tool.meta["ui"]["resourceUri"] == "ui://app" + assert mcp_tool.meta["ui"]["visibility"] == ["app"] + + async def test_tool_without_ui_has_no_ui_meta(self): + server = FastMCP("test") + + @server.tool + def my_tool() -> str: + return "hello" + + tools = list(await server.list_tools()) + meta = tools[0].meta + assert meta is None or "ui" not in meta + + +# --------------------------------------------------------------------------- +# Resource registration with ui:// and ui= +# --------------------------------------------------------------------------- + + +class TestResourceWithUI: + async def test_ui_scheme_defaults_mime_type(self): + server = FastMCP("test") + + @server.resource("ui://my-app/view.html") + def app_html() -> str: + return "hello" + + resources = list(await server.list_resources()) + assert len(resources) == 1 + assert resources[0].mime_type == UI_MIME_TYPE + + async def test_explicit_mime_type_overrides_ui_default(self): + server = FastMCP("test") + + @server.resource("ui://my-app/view.html", mime_type="text/html") + def app_html() -> str: + return "hello" + + resources = list(await server.list_resources()) + assert resources[0].mime_type == "text/html" + + async def test_resource_ui_metadata(self): + server = FastMCP("test") + + @server.resource( + "ui://my-app/view.html", + ui=ResourceUI(prefers_border=True), + ) + def app_html() -> str: + return "hello" + + resources = list(await server.list_resources()) + assert resources[0].meta is not None + assert resources[0].meta["ui"]["prefersBorder"] is True + + async def test_non_ui_scheme_no_mime_default(self): + server = FastMCP("test") + + @server.resource("resource://data") + def data() -> str: + return "data" + + resources = list(await server.list_resources()) + assert resources[0].mime_type != UI_MIME_TYPE + + +# --------------------------------------------------------------------------- +# Extension advertisement +# --------------------------------------------------------------------------- + + +class TestExtensionAdvertisement: + async def test_capabilities_include_ui_extension(self): + server = FastMCP("test") + + @server.tool + def my_tool() -> str: + return "hello" + + async with Client(server) as client: + init_result = client.initialize_result + extras = init_result.capabilities.model_extra or {} + extensions = extras.get("extensions", {}) + assert UI_EXTENSION_ID in extensions + + +# --------------------------------------------------------------------------- +# Context.client_supports_extension +# --------------------------------------------------------------------------- + + +class TestContextClientSupportsExtension: + async def test_returns_false_when_no_session(self): + server = FastMCP("test") + async with Context(fastmcp=server) as ctx: + assert ctx.client_supports_extension(UI_EXTENSION_ID) is False + + +# --------------------------------------------------------------------------- +# Integration — full client↔server round-trip +# --------------------------------------------------------------------------- + + +class TestIntegration: + async def test_tool_with_ui_roundtrip(self): + """UI metadata flows through to clients — no server-side stripping.""" + server = FastMCP("test") + + @server.tool(ui=ToolUI(resource_uri="ui://app/view.html", visibility=["app"])) + async def my_tool() -> dict[str, str]: + return {"result": "ok"} + + async with Client(server) as client: + tools = await client.list_tools() + assert len(tools) == 1 + # _meta.ui is preserved — the host decides what to do with it + meta = tools[0].meta + assert meta is not None + assert meta["ui"]["resourceUri"] == "ui://app/view.html" + assert meta["ui"]["visibility"] == ["app"] + + async def test_resource_with_ui_scheme_roundtrip(self): + server = FastMCP("test") + + @server.resource("ui://my-app/view.html") + def app_html() -> str: + return "Hello" + + async with Client(server) as client: + resources = await client.list_resources() + assert len(resources) == 1 + assert str(resources[0].uri) == "ui://my-app/view.html" + assert resources[0].mimeType == UI_MIME_TYPE + + async def test_ui_tool_callable(self): + """A tool registered with ui= is still callable normally.""" + server = FastMCP("test") + + @server.tool(ui=ToolUI(resource_uri="ui://app")) + async def greet(name: str) -> str: + return f"Hello, {name}!" + + async with Client(server) as client: + result = await client.call_tool("greet", {"name": "Alice"}) + assert any("Hello, Alice!" in str(c) for c in result.content) + + async def test_extension_and_tool_together(self): + """Server advertises extension AND tool has UI meta (stored on FastMCP Tool).""" + server = FastMCP("test") + + @server.tool(ui=ToolUI(resource_uri="ui://dashboard", visibility=["app"])) + def dashboard() -> str: + return "data" + + # Verify the stored FastMCP Tool still has full metadata + tools = list(await server.list_tools()) + assert tools[0].meta is not None + assert tools[0].meta["ui"]["resourceUri"] == "ui://dashboard" + + # Verify the server advertises the extension + async with Client(server) as client: + extras = client.initialize_result.capabilities.model_extra or {} + assert UI_EXTENSION_ID in extras.get("extensions", {}) From b63f2316dea87ceaa98ad5467482f021f6d2894c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:00:31 -0500 Subject: [PATCH 2/6] Fix ty type-narrowing errors in skills.py and test_dependencies.py --- src/fastmcp/utilities/skills.py | 4 +--- tests/server/test_dependencies.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/utilities/skills.py b/src/fastmcp/utilities/skills.py index ae018d47da..a84728fe3b 100644 --- a/src/fastmcp/utilities/skills.py +++ b/src/fastmcp/utilities/skills.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import json from dataclasses import dataclass from pathlib import Path @@ -202,9 +203,6 @@ async def download_skill( if isinstance(content, mcp.types.TextResourceContents): file_path.write_text(content.text) elif isinstance(content, mcp.types.BlobResourceContents): - # Handle base64-encoded binary content - import base64 - file_path.write_bytes(base64.b64decode(content.blob)) else: # Skip unknown content types diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index 7d4119c8e4..60640e1b41 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -67,7 +67,7 @@ async def get_user_id() -> int: return 42 @mcp.tool() - async def greet_user(name: str, user_id: int = Depends(get_user_id)) -> str: + async def greet_user(name: str, user_id: int = Depends(get_user_id)) -> str: # type: ignore[invalid-parameter-default] return f"Hello {name}, your ID is {user_id}" result = await mcp.call_tool("greet_user", {"name": "Alice"}) From b7c7df0d98315dbc4f49e041619a2aafd23cd71b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:22:57 -0500 Subject: [PATCH 3/6] Apply ui:// MIME default across all resource registration paths Per review feedback: the ui:// MIME type default was only being applied in FastMCP.resource(), leaving standalone @resource decorators and resource templates with the generic text/plain default. Now resolve_ui_mime_type() is used in all three paths, so ui:// resources are correctly identified regardless of how they're registered. --- src/fastmcp/resources/function_resource.py | 6 ++++- src/fastmcp/resources/template.py | 6 ++++- src/fastmcp/server/apps.py | 22 ++++++++++++++++++ src/fastmcp/server/server.py | 13 +++++++---- tests/server/test_dependencies.py | 2 +- tests/test_apps.py | 27 ++++++++++++++++++++++ 6 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py index b7fa5bb7a8..c6a881dab7 100644 --- a/src/fastmcp/resources/function_resource.py +++ b/src/fastmcp/resources/function_resource.py @@ -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, @@ -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, @@ -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, diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 1ef6c7e18f..02836e3982 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -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, @@ -567,6 +568,9 @@ 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, @@ -574,7 +578,7 @@ def from_function( 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(), diff --git a/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py index de1281f8e4..b8ff1a1b48 100644 --- a/src/fastmcp/server/apps.py +++ b/src/fastmcp/server/apps.py @@ -68,3 +68,25 @@ def ui_to_meta_dict(ui: ToolUI | ResourceUI | dict[str, Any]) -> dict[str, Any]: 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 + if uri.startswith("ui://"): + return UI_MIME_TYPE + return None diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index d1ca8539d8..c5a9dcc1cb 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -58,7 +58,12 @@ from fastmcp.prompts.prompt import PromptResult from fastmcp.resources.resource import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.apps import UI_MIME_TYPE, ResourceUI, ToolUI, ui_to_meta_dict +from fastmcp.server.apps import ( + ResourceUI, + ToolUI, + 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 from fastmcp.server.lifespan import Lifespan @@ -1587,9 +1592,9 @@ async def get_weather(city: str) -> str: return f"Weather for {city}: {data}" ``` """ - # Default MIME type for ui:// scheme resources - if mime_type is None and isinstance(uri, str) and uri.startswith("ui://"): - mime_type = UI_MIME_TYPE + # Apply default MIME type for ui:// scheme resources + if isinstance(uri, str): + mime_type = resolve_ui_mime_type(uri, mime_type) # Merge UI metadata into meta["ui"] before passing to provider if ui is not None: diff --git a/tests/server/test_dependencies.py b/tests/server/test_dependencies.py index 60640e1b41..7d4119c8e4 100644 --- a/tests/server/test_dependencies.py +++ b/tests/server/test_dependencies.py @@ -67,7 +67,7 @@ async def get_user_id() -> int: return 42 @mcp.tool() - async def greet_user(name: str, user_id: int = Depends(get_user_id)) -> str: # type: ignore[invalid-parameter-default] + async def greet_user(name: str, user_id: int = Depends(get_user_id)) -> str: return f"Hello {name}, your ID is {user_id}" result = await mcp.call_tool("greet_user", {"name": "Alice"}) diff --git a/tests/test_apps.py b/tests/test_apps.py index 5e6be84a35..6081de7a24 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -207,6 +207,33 @@ def data() -> str: resources = list(await server.list_resources()) assert resources[0].mime_type != UI_MIME_TYPE + async def test_standalone_decorator_ui_scheme_defaults_mime_type(self): + """Test that the standalone @resource decorator also applies ui:// MIME default.""" + from fastmcp.resources import resource + + @resource("ui://standalone-app/view.html") + def standalone_app() -> str: + return "standalone" + + server = FastMCP("test") + server.add_resource(standalone_app) + + resources = list(await server.list_resources()) + assert len(resources) == 1 + assert resources[0].mime_type == UI_MIME_TYPE + + async def test_resource_template_ui_scheme_defaults_mime_type(self): + """Test that resource templates also apply ui:// MIME default.""" + server = FastMCP("test") + + @server.resource("ui://template-app/{view}") + def template_app(view: str) -> str: + return f"{view}" + + templates = list(await server.list_resource_templates()) + assert len(templates) == 1 + assert templates[0].mime_type == UI_MIME_TYPE + # --------------------------------------------------------------------------- # Extension advertisement From 7efc3bc44127565d3553887e6f3c22a51549f235 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:21:30 -0500 Subject: [PATCH 4/6] Address review nitpicks: case-insensitive URI scheme, remove redundant check, fix docs example --- docs/development/v3-notes/v3-features.mdx | 3 ++- src/fastmcp/server/apps.py | 3 ++- src/fastmcp/server/server.py | 3 +-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index 95bc1e6bef..516d14b8bc 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -649,7 +649,8 @@ mcp = FastMCP("My Server") # Register the HTML bundle as a ui:// resource @mcp.resource("ui://my-app/view.html") def app_html() -> str: - return open("./dist/index.html").read() + 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")) diff --git a/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py index b8ff1a1b48..2e22511a64 100644 --- a/src/fastmcp/server/apps.py +++ b/src/fastmcp/server/apps.py @@ -87,6 +87,7 @@ def resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None """ if explicit_mime_type is not None: return explicit_mime_type - if uri.startswith("ui://"): + # Case-insensitive scheme check per RFC 3986 + if uri.lower().startswith("ui://"): return UI_MIME_TYPE return None diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c5a9dcc1cb..93aeb75e6f 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1593,8 +1593,7 @@ async def get_weather(city: str) -> str: ``` """ # Apply default MIME type for ui:// scheme resources - if isinstance(uri, str): - mime_type = resolve_ui_mime_type(uri, mime_type) + mime_type = resolve_ui_mime_type(uri, mime_type) # Merge UI metadata into meta["ui"] before passing to provider if ui is not None: From b5eb878978e6d213096a9a105c935e5ee1ce1308 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:45:07 -0500 Subject: [PATCH 5/6] Restore isinstance check for incorrect decorator usage detection --- src/fastmcp/server/server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 93aeb75e6f..220538af52 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1593,7 +1593,9 @@ async def get_weather(city: str) -> str: ``` """ # Apply default MIME type for ui:// scheme resources - mime_type = resolve_ui_mime_type(uri, mime_type) + # (isinstance check needed because incorrect decorator usage passes a function) + if isinstance(uri, str): + mime_type = resolve_ui_mime_type(uri, mime_type) # Merge UI metadata into meta["ui"] before passing to provider if ui is not None: From eec2da9f53ea3d7b974ab92232eba6529b2be81f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:55:31 -0500 Subject: [PATCH 6/6] Validate uri is string early in FastMCP.resource() before processing --- src/fastmcp/server/server.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 220538af52..a09b58a6e1 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1592,10 +1592,16 @@ async def get_weather(city: str) -> str: return f"Weather for {city}: {data}" ``` """ + # Catch incorrect decorator usage early (before any processing) + if not isinstance(uri, str): + raise TypeError( + "The @resource decorator was used incorrectly. " + "It requires a URI as the first argument. " + "Use @resource('uri') instead of @resource" + ) + # Apply default MIME type for ui:// scheme resources - # (isinstance check needed because incorrect decorator usage passes a function) - if isinstance(uri, str): - mime_type = resolve_ui_mime_type(uri, mime_type) + mime_type = resolve_ui_mime_type(uri, mime_type) # Merge UI metadata into meta["ui"] before passing to provider if ui is not None: