diff --git a/src/fastmcp/apps/__init__.py b/src/fastmcp/apps/__init__.py index fe8f887b5..d8fc21696 100644 --- a/src/fastmcp/apps/__init__.py +++ b/src/fastmcp/apps/__init__.py @@ -9,6 +9,7 @@ This package contains the app-related components: from fastmcp.apps.app import FastMCPApp as FastMCPApp from fastmcp.apps.config import AppConfig as AppConfig +from fastmcp.apps.config import PrefabAppConfig as PrefabAppConfig from fastmcp.apps.config import ResourceCSP as ResourceCSP from fastmcp.apps.config import ResourcePermissions as ResourcePermissions from fastmcp.apps.config import UI_EXTENSION_ID as UI_EXTENSION_ID diff --git a/src/fastmcp/apps/config.py b/src/fastmcp/apps/config.py index f45a0c40e..c55cd0b13 100644 --- a/src/fastmcp/apps/config.py +++ b/src/fastmcp/apps/config.py @@ -114,6 +114,62 @@ class AppConfig(BaseModel): model_config = {"populate_by_name": True, "extra": "allow"} +class PrefabAppConfig(AppConfig): + """App configuration for Prefab tools with sensible defaults. + + Like ``app=True`` but customizable. Auto-wires the Prefab renderer + URI and merges the renderer's CSP with any additional domains you + specify. The renderer resource is registered automatically. + + Example:: + + @mcp.tool(app=PrefabAppConfig()) # same as app=True + + @mcp.tool(app=PrefabAppConfig( + csp=ResourceCSP(frame_domains=["https://example.com"]), + )) + """ + + def model_post_init(self, __context: Any) -> None: + # Set the renderer URI if not explicitly overridden + if self.resource_uri is None: + self.resource_uri = "ui://prefab/renderer.html" + + # Merge renderer CSP with user-provided CSP + try: + from prefab_ui.renderer import get_renderer_csp + + renderer_csp = get_renderer_csp() + except ImportError: + renderer_csp = {} + + if renderer_csp: + user_csp = self.csp or ResourceCSP() + # Start from the user's CSP (preserves model_extra for + # forward-compat directives), then merge renderer domains. + merged_data = user_csp.model_dump(exclude_none=True) + merged_data["connect_domains"] = _merge_domains( + renderer_csp.get("connect_domains"), + user_csp.connect_domains, + ) + merged_data["resource_domains"] = _merge_domains( + renderer_csp.get("resource_domains"), + user_csp.resource_domains, + ) + self.csp = ResourceCSP(**merged_data) + + +def _merge_domains(base: list[str] | None, extra: list[str] | None) -> list[str] | None: + """Merge two domain lists, deduplicating.""" + if base is None and extra is None: + return None + combined = list(base or []) + for d in extra or []: + if d not in combined: + combined.append(d) + return combined or None + + 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): diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py index 1c9381269..e79cc2c65 100644 --- a/src/fastmcp/server/providers/local_provider/decorators/tools.py +++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py @@ -141,7 +141,10 @@ def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None: # Inference: return type is a prefab type, auto-wire _ensure_prefab_renderer(provider) _expand_prefab_ui_meta(tool) - # If ui is a dict, it's already manually configured — leave it alone + elif isinstance(ui, dict) and ui.get("resourceUri") == PREFAB_RENDERER_URI: + # PrefabAppConfig or manual config pointing to the Prefab renderer — + # ensure the renderer resource is registered (CSP already set by caller) + _ensure_prefab_renderer(provider) class ToolDecoratorMixin: diff --git a/tests/test_apps.py b/tests/test_apps.py index 18b895f90..125772897 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -561,3 +561,64 @@ class TestIntegration: assert content_item.meta["ui"]["csp"]["resourceDomains"] == [ "https://unpkg.com" ] + + +# --------------------------------------------------------------------------- +# PrefabAppConfig +# --------------------------------------------------------------------------- + + +class TestPrefabAppConfig: + def test_default_sets_renderer_uri(self): + from fastmcp.apps import PrefabAppConfig + + config = PrefabAppConfig() + assert config.resource_uri == "ui://prefab/renderer.html" + + def test_merges_renderer_csp_with_user_csp(self): + from fastmcp.apps import PrefabAppConfig + + config = PrefabAppConfig( + csp=ResourceCSP(frame_domains=["https://example.com"]), + ) + assert config.resource_uri == "ui://prefab/renderer.html" + assert config.csp is not None + assert config.csp.frame_domains == ["https://example.com"] + + async def test_auto_registers_renderer_resource(self): + from fastmcp.apps import PrefabAppConfig + + server = FastMCP("test") + + @server.tool(app=PrefabAppConfig()) + def my_tool() -> str: + return "hello" + + resources = list(await server.list_resources()) + uris = [str(r.uri) for r in resources] + assert any("ui://prefab/renderer.html" in u for u in uris) + + async def test_equivalent_to_app_true(self): + """PrefabAppConfig() should produce the same tool metadata as app=True.""" + from fastmcp.apps import PrefabAppConfig + + server1 = FastMCP("test1") + server2 = FastMCP("test2") + + @server1.tool(app=True) + def tool_a() -> str: + return "a" + + @server2.tool(app=PrefabAppConfig()) + def tool_b() -> str: + return "b" + + tools1 = list(await server1.list_tools()) + tools2 = list(await server2.list_tools()) + + assert tools1[0].meta is not None + ui1 = tools1[0].meta.get("ui", {}) + assert tools2[0].meta is not None + ui2 = tools2[0].meta.get("ui", {}) + + assert ui1.get("resourceUri") == ui2.get("resourceUri")