mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-19 20:14:17 +02:00
Add PrefabAppConfig for customizable Prefab tool setup (#3648)
This commit is contained in:
parent
77d8b3ae97
commit
b7cb7cea81
4 changed files with 122 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue