Add PrefabAppConfig for customizable Prefab tool setup (#3648)

This commit is contained in:
Jeremiah Lowin 2026-03-27 10:08:48 -04:00 committed by GitHub
commit b7cb7cea81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 122 additions and 1 deletions

View file

@ -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

View file

@ -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):

View file

@ -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:

View file

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