diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx index f982513d92..90d72f4165 100644 --- a/docs/development/v3-notes/v3-features.mdx +++ b/docs/development/v3-notes/v3-features.mdx @@ -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={}), ), @@ -224,17 +226,17 @@ 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. @@ -242,9 +244,9 @@ The `ui=` parameter accepts either a typed model (`ToolUI`, `ResourceUI`) or a r ```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): @@ -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 - 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 diff --git a/examples/apps/qr_server/README.md b/examples/apps/qr_server/README.md index f2c9ebf1d0..2cfe806b12 100644 --- a/examples/apps/qr_server/README.md +++ b/examples/apps/qr_server/README.md @@ -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 diff --git a/examples/apps/qr_server/qr_server.py b/examples/apps/qr_server/qr_server.py index 8478cd5579..7a3d5ee649 100644 --- a/examples/apps/qr_server/qr_server.py +++ b/examples/apps/qr_server/qr_server.py @@ -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 @@ -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" @@ -104,7 +104,7 @@ """ -@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, @@ -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.""" diff --git a/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py index 566938b987..9da7bc8e25 100644 --- a/src/fastmcp/server/apps.py +++ b/src/fastmcp/server/apps.py @@ -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 @@ -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" ) @@ -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: diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py index bff0443a51..7527f1b763 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py @@ -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) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 27494f7067..7dc7478216 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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 @@ -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, @@ -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, @@ -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, @@ -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( @@ -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]: @@ -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( diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py index 7ef6df3982..812f5108cf 100644 --- a/src/fastmcp/tools/function_tool.py +++ b/src/fastmcp/tools/function_tool.py @@ -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 diff --git a/tests/test_apps.py b/tests/test_apps.py index 348eab36b4..5d41897a29 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -1,6 +1,6 @@ """Tests for MCP Apps Phase 1 — SDK compatibility. -Covers UI metadata models, tool/resource registration with ``ui=``, +Covers app config models, tool/resource registration with ``app=``, extension negotiation, and the ``Context.client_supports_extension`` method. """ @@ -8,15 +8,16 @@ from typing import Any +import pytest + from fastmcp import Client, FastMCP from fastmcp.server.apps import ( UI_EXTENSION_ID, UI_MIME_TYPE, + AppConfig, ResourceCSP, ResourcePermissions, - ResourceUI, - ToolUI, - ui_to_meta_dict, + app_config_to_meta_dict, ) from fastmcp.server.context import Context @@ -25,19 +26,19 @@ # --------------------------------------------------------------------------- -class TestToolUI: +class TestAppConfig: 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) + cfg = AppConfig(resource_uri="ui://my-app/view.html", visibility=["app"]) + d = cfg.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) + cfg = AppConfig(resource_uri="ui://foo") + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == {"resourceUri": "ui://foo"} def test_all_fields(self): - ui = ToolUI( + cfg = AppConfig( resource_uri="ui://app", visibility=["app", "model"], csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), @@ -45,7 +46,7 @@ def test_all_fields(self): domain="example.com", prefers_border=True, ) - d = ui.model_dump(by_alias=True, exclude_none=True) + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == { "resourceUri": "ui://app", "visibility": ["app", "model"], @@ -56,8 +57,8 @@ def test_all_fields(self): } def test_populate_by_name(self): - ui = ToolUI(resource_uri="ui://app") - assert ui.resource_uri == "ui://app" + cfg = AppConfig(resource_uri="ui://app") + assert cfg.resource_uri == "ui://app" class TestResourceCSP: @@ -152,61 +153,63 @@ def test_empty(self): assert d == {} -class TestResourceUI: +class TestAppConfigForResources: + """AppConfig without resource_uri/visibility — for use on resources.""" + def test_serializes_with_aliases(self): - ui = ResourceUI( + cfg = AppConfig( prefers_border=True, csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), ) - d = ui.model_dump(by_alias=True, exclude_none=True) + d = cfg.model_dump(by_alias=True, exclude_none=True) 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) + cfg = AppConfig() + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == {} def test_with_permissions(self): - ui = ResourceUI( + cfg = AppConfig( permissions=ResourcePermissions(microphone={}, clipboard_write={}), ) - d = ui.model_dump(by_alias=True, exclude_none=True) + d = cfg.model_dump(by_alias=True, exclude_none=True) assert d == { "permissions": {"microphone": {}, "clipboardWrite": {}}, } -class TestUIToMetaDict: - def test_from_tool_ui(self): - ui = ToolUI(resource_uri="ui://app", visibility=["app"]) - result = ui_to_meta_dict(ui) +class TestAppConfigToMetaDict: + def test_from_app_config_with_tool_fields(self): + cfg = AppConfig(resource_uri="ui://app", visibility=["app"]) + result = app_config_to_meta_dict(cfg) 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) + def test_from_app_config_resource_fields_only(self): + cfg = AppConfig(prefers_border=False) + result = app_config_to_meta_dict(cfg) 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) + result = app_config_to_meta_dict(raw) assert result is raw # --------------------------------------------------------------------------- -# Tool registration with ui= +# Tool registration with app= # --------------------------------------------------------------------------- -class TestToolRegistrationWithUI: - async def test_tool_ui_model(self): +class TestToolRegistrationWithApp: + async def test_app_config_model(self): server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://my-app/view.html")) + @server.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) def my_tool() -> str: return "hello" @@ -215,10 +218,10 @@ def my_tool() -> str: 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): + async def test_app_dict(self): server = FastMCP("test") - @server.tool(ui={"resourceUri": "ui://foo", "visibility": ["app"]}) + @server.tool(app={"resourceUri": "ui://foo", "visibility": ["app"]}) def my_tool() -> str: return "hello" @@ -227,10 +230,10 @@ def my_tool() -> str: assert tools[0].meta["ui"]["resourceUri"] == "ui://foo" assert tools[0].meta["ui"]["visibility"] == ["app"] - async def test_ui_merges_with_existing_meta(self): + async def test_app_merges_with_existing_meta(self): server = FastMCP("test") - @server.tool(meta={"custom": "data"}, ui=ToolUI(resource_uri="ui://app")) + @server.tool(meta={"custom": "data"}, app=AppConfig(resource_uri="ui://app")) def my_tool() -> str: return "hello" @@ -240,10 +243,10 @@ def my_tool() -> str: assert meta["custom"] == "data" assert meta["ui"]["resourceUri"] == "ui://app" - async def test_ui_in_mcp_wire_format(self): + async def test_app_in_mcp_wire_format(self): server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://app", visibility=["app"])) + @server.tool(app=AppConfig(resource_uri="ui://app", visibility=["app"])) def my_tool() -> str: return "hello" @@ -253,7 +256,7 @@ def my_tool() -> str: 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): + async def test_tool_without_app_has_no_ui_meta(self): server = FastMCP("test") @server.tool @@ -266,11 +269,11 @@ def my_tool() -> str: # --------------------------------------------------------------------------- -# Resource registration with ui:// and ui= +# Resource registration with ui:// and app= # --------------------------------------------------------------------------- -class TestResourceWithUI: +class TestResourceWithApp: async def test_ui_scheme_defaults_mime_type(self): server = FastMCP("test") @@ -292,12 +295,12 @@ def app_html() -> str: resources = list(await server.list_resources()) assert resources[0].mime_type == "text/html" - async def test_resource_ui_metadata(self): + async def test_resource_app_metadata(self): server = FastMCP("test") @server.resource( "ui://my-app/view.html", - ui=ResourceUI(prefers_border=True), + app=AppConfig(prefers_border=True), ) def app_html() -> str: return "hello" @@ -317,7 +320,7 @@ def data() -> str: 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.""" + """The standalone @resource decorator also applies ui:// MIME default.""" from fastmcp.resources import resource @resource("ui://standalone-app/view.html") @@ -332,7 +335,7 @@ def standalone_app() -> str: 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.""" + """Resource templates also apply ui:// MIME default.""" server = FastMCP("test") @server.resource("ui://template-app/{view}") @@ -343,6 +346,30 @@ def template_app(view: str) -> str: assert len(templates) == 1 assert templates[0].mime_type == UI_MIME_TYPE + async def test_resource_rejects_resource_uri(self): + """AppConfig with resource_uri raises ValueError on resources.""" + server = FastMCP("test") + with pytest.raises(ValueError, match="resource_uri cannot be set on resources"): + + @server.resource( + "ui://my-app/view.html", + app=AppConfig(resource_uri="ui://other"), + ) + def app_html() -> str: + return "hello" + + async def test_resource_rejects_visibility(self): + """AppConfig with visibility raises ValueError on resources.""" + server = FastMCP("test") + with pytest.raises(ValueError, match="visibility cannot be set on resources"): + + @server.resource( + "ui://my-app/view.html", + app=AppConfig(visibility=["app"]), + ) + def app_html() -> str: + return "hello" + # --------------------------------------------------------------------------- # Extension advertisement @@ -382,11 +409,13 @@ async def test_returns_false_when_no_session(self): class TestIntegration: - async def test_tool_with_ui_roundtrip(self): - """UI metadata flows through to clients — no server-side stripping.""" + async def test_tool_with_app_roundtrip(self): + """App metadata flows through to clients — no server-side stripping.""" server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://app/view.html", visibility=["app"])) + @server.tool( + app=AppConfig(resource_uri="ui://app/view.html", visibility=["app"]) + ) async def my_tool() -> dict[str, str]: return {"result": "ok"} @@ -425,11 +454,11 @@ def app_html() -> str: 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.""" + async def test_app_tool_callable(self): + """A tool registered with app= is still callable normally.""" server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://app")) + @server.tool(app=AppConfig(resource_uri="ui://app")) async def greet(name: str) -> str: return f"Hello, {name}!" @@ -438,19 +467,17 @@ async def greet(name: str) -> str: 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 advertises extension AND tool has app meta.""" server = FastMCP("test") - @server.tool(ui=ToolUI(resource_uri="ui://dashboard", visibility=["app"])) + @server.tool(app=AppConfig(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", {}) @@ -461,7 +488,7 @@ async def test_csp_and_permissions_roundtrip(self): @server.resource( "ui://secure-app/view.html", - ui=ResourceUI( + app=AppConfig( csp=ResourceCSP( resource_domains=["https://unpkg.com"], connect_domains=["https://api.example.com"], @@ -473,7 +500,7 @@ def secure_app() -> str: return "secure" @server.tool( - ui=ToolUI( + app=AppConfig( resource_uri="ui://secure-app/view.html", csp=ResourceCSP(resource_domains=["https://cdn.example.com"]), permissions=ResourcePermissions(camera={}), @@ -509,7 +536,7 @@ async def test_resource_read_propagates_meta_to_content_items(self): @server.resource( "ui://csp-app/view.html", - ui=ResourceUI( + app=AppConfig( csp=ResourceCSP(resource_domains=["https://unpkg.com"]), ), )