mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-16 18:49:14 +02:00
* Add prefab auto-wiring for MCP Apps (#3119) Tools that return prefab types (UIResponse, Component) automatically get wired to the shared prefab renderer resource. Works via app=True, return type inference, or both. * Prefab compatibility updates * Use published prefab-ui >=0.6.0, remove local source override * Migrate UIResponse to PrefabApp for Prefab UI integration PrefabApp is a pure data object with to_json(), html(), and csp() methods. Tools can return PrefabApp, bare Components, or ToolResult with structured_content for custom LLM fallback text. * Add Prefab UI apps documentation * Add mini apps and full apps documentation pages Mini apps covers the common single-screen patterns: charts (bar, line, area, pie), data tables with sorting/search/pagination, forms (manual and Pydantic-generated), status displays, conditional content, and layout composition with tabs and accordions. Full apps covers multi-page applications using Pages/Page components, shared state across pages, and using ToolCall with result_key for server-driven state updates. * Reframe apps docs around motivation, add generative UIs page The docs now lead with the problem — MCP tools stuff data into the LLM context window, and building HTML/JS/CSS frontends is a non-starter for Python developers — before introducing Prefab as the solution. Mini apps are framed as the primary use case: focused, single-purpose UIs that present data visually and collect structured input. New generative UIs page covers the concept of LLMs producing component JSON directly, enabling adaptive dashboards, tailored forms, and exploratory workflows. * Tag Prefab docs pages as SOON instead of NEW * Rename Low-Level API to Custom HTML Apps The page is about using the MCP Apps extension directly, not a FastMCP or Prefab internal API. Reframed to make clear this is the open MCP protocol with FastMCP providing convenience wrappers. * Tighten apps docs and widen content area Strip editorial motivation from all app doc pages — let code examples do the talking. Add content-area max-width override (44rem) to style.css. * Restructure apps docs, fix code issues Rename Prefab UI → Prefab Apps, mini-apps → patterns, remove generative-uis and full-apps pages. Rewrite prefab page to lead with what users do (declare a UI, return it) before explaining internals. Patterns page now has fully self-contained copy-pasteable examples with explicit imports and links to prefab docs. Forms show the two-tool pattern (form + handler). Add patterns_server.py example. Code fixes: move get_args to module-level import, remove dead AuthCheckCallable type alias, fix ToolCall→CallTool in all docs. * Remove unused ToolResult import from chart_server * Handle composite Prefab types in type inference and schema suppression _has_prefab_return_type and the output schema suppression logic only checked bare classes, missing unions (Column | None) and Annotated wrappers (Annotated[PrefabApp | None, ...]). Recurse through Union, types.UnionType, and Annotated to detect Prefab types in composite annotations.
431 lines
14 KiB
Python
431 lines
14 KiB
Python
"""Tests for MCP Apps Phase 2 — Prefab integration.
|
|
|
|
Covers ``convert_result`` for PrefabApp/Component, ``app=True`` auto-wiring,
|
|
return-type inference, output-schema suppression, and end-to-end round trips.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Annotated
|
|
|
|
from mcp.types import TextContent
|
|
from prefab_ui.app import PrefabApp
|
|
from prefab_ui.components import Column, Heading, Text
|
|
from prefab_ui.components.base import Component
|
|
|
|
from fastmcp import Client, FastMCP
|
|
from fastmcp.resources.types import TextResource
|
|
from fastmcp.server.apps import UI_MIME_TYPE, AppConfig
|
|
from fastmcp.server.providers.local_provider.decorators.tools import (
|
|
PREFAB_RENDERER_URI,
|
|
)
|
|
from fastmcp.tools.tool import Tool, ToolResult
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# convert_result
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestConvertResult:
|
|
def test_prefab_app(self):
|
|
with Column() as view:
|
|
Heading("Hello")
|
|
app = PrefabApp(view=view, state={"name": "Alice"})
|
|
|
|
tool = Tool(name="t", parameters={})
|
|
result = tool.convert_result(app)
|
|
|
|
assert isinstance(result, ToolResult)
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert result.content[0].text == "[Rendered Prefab UI]"
|
|
assert result.structured_content is not None
|
|
assert result.structured_content["version"] == "0.2"
|
|
assert result.structured_content["state"] == {"name": "Alice"}
|
|
assert result.structured_content["view"]["type"] == "Column"
|
|
|
|
def test_bare_component(self):
|
|
heading = Heading("World")
|
|
|
|
tool = Tool(name="t", parameters={})
|
|
result = tool.convert_result(heading)
|
|
|
|
assert isinstance(result, ToolResult)
|
|
assert result.structured_content is not None
|
|
assert result.structured_content["version"] == "0.2"
|
|
assert result.structured_content["view"]["type"] == "Heading"
|
|
|
|
def test_tool_result_with_prefab_structured_content(self):
|
|
"""ToolResult with PrefabApp as structured_content preserves custom text."""
|
|
app = PrefabApp(view=Heading("Hello"), state={"x": 1})
|
|
|
|
tool = Tool(name="t", parameters={})
|
|
result = tool.convert_result(
|
|
ToolResult(content="Custom fallback text", structured_content=app)
|
|
)
|
|
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert result.content[0].text == "Custom fallback text"
|
|
assert result.structured_content is not None
|
|
assert result.structured_content["version"] == "0.2"
|
|
assert result.structured_content["view"]["type"] == "Heading"
|
|
|
|
def test_tool_result_with_component_structured_content(self):
|
|
"""ToolResult with bare Component as structured_content."""
|
|
tool = Tool(name="t", parameters={})
|
|
result = tool.convert_result(
|
|
ToolResult(content="My text", structured_content=Heading("Hi"))
|
|
)
|
|
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert result.content[0].text == "My text"
|
|
assert result.structured_content is not None
|
|
assert result.structured_content["version"] == "0.2"
|
|
assert result.structured_content["view"]["type"] == "Heading"
|
|
|
|
def test_tool_result_passthrough(self):
|
|
"""ToolResult without prefab structured_content passes through unchanged."""
|
|
original = ToolResult(content="hello")
|
|
tool = Tool(name="t", parameters={})
|
|
assert tool.convert_result(original) is original
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# app=True auto-wiring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAppTrue:
|
|
def test_app_true_sets_meta(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(app=True)
|
|
def my_tool() -> str:
|
|
return "hello"
|
|
|
|
tools = mcp._local_provider._components
|
|
tool = next(
|
|
v
|
|
for v in tools.values()
|
|
if hasattr(v, "parameters") and v.name == "my_tool"
|
|
)
|
|
assert tool.meta is not None
|
|
assert "ui" in tool.meta
|
|
assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI
|
|
|
|
def test_app_true_registers_renderer_resource(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(app=True)
|
|
def my_tool() -> str:
|
|
return "hello"
|
|
|
|
renderer_key = f"resource:{PREFAB_RENDERER_URI}@"
|
|
assert renderer_key in mcp._local_provider._components
|
|
|
|
def test_renderer_resource_has_correct_mime_type(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(app=True)
|
|
def my_tool() -> str:
|
|
return "hello"
|
|
|
|
renderer_key = f"resource:{PREFAB_RENDERER_URI}@"
|
|
resource = mcp._local_provider._components[renderer_key]
|
|
assert isinstance(resource, TextResource)
|
|
assert resource.mime_type == UI_MIME_TYPE
|
|
|
|
def test_renderer_resource_has_csp(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(app=True)
|
|
def my_tool() -> str:
|
|
return "hello"
|
|
|
|
renderer_key = f"resource:{PREFAB_RENDERER_URI}@"
|
|
resource = mcp._local_provider._components[renderer_key]
|
|
assert resource.meta is not None
|
|
assert "ui" in resource.meta
|
|
assert "csp" in resource.meta["ui"]
|
|
|
|
def test_multiple_tools_share_renderer(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(app=True)
|
|
def tool_a() -> str:
|
|
return "a"
|
|
|
|
@mcp.tool(app=True)
|
|
def tool_b() -> str:
|
|
return "b"
|
|
|
|
renderer_keys = [
|
|
k for k in mcp._local_provider._components if k.startswith("resource:ui://")
|
|
]
|
|
assert len(renderer_keys) == 1
|
|
|
|
def test_explicit_app_config_not_overridden(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(app=AppConfig(resource_uri="ui://custom/app.html"))
|
|
def my_tool() -> PrefabApp:
|
|
return PrefabApp(view=Heading("hi"))
|
|
|
|
tools = mcp._local_provider._components
|
|
tool = next(
|
|
v
|
|
for v in tools.values()
|
|
if hasattr(v, "parameters") and v.name == "my_tool"
|
|
)
|
|
assert tool.meta is not None
|
|
assert tool.meta["ui"]["resourceUri"] == "ui://custom/app.html"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Return type inference
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInference:
|
|
def test_prefab_app_annotation_inferred(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool
|
|
def my_tool() -> PrefabApp:
|
|
return PrefabApp(view=Heading("hi"))
|
|
|
|
tools = mcp._local_provider._components
|
|
tool = next(
|
|
v
|
|
for v in tools.values()
|
|
if hasattr(v, "parameters") and v.name == "my_tool"
|
|
)
|
|
assert tool.meta is not None
|
|
assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI
|
|
|
|
def test_component_annotation_inferred(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool
|
|
def my_tool() -> Component:
|
|
return Heading("hi")
|
|
|
|
tools = mcp._local_provider._components
|
|
tool = next(
|
|
v
|
|
for v in tools.values()
|
|
if hasattr(v, "parameters") and v.name == "my_tool"
|
|
)
|
|
assert tool.meta is not None
|
|
assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI
|
|
|
|
def test_no_annotation_no_inference(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool
|
|
def my_tool():
|
|
return "hello"
|
|
|
|
tools = mcp._local_provider._components
|
|
tool = next(
|
|
v
|
|
for v in tools.values()
|
|
if hasattr(v, "parameters") and v.name == "my_tool"
|
|
)
|
|
assert tool.meta is None or "ui" not in (tool.meta or {})
|
|
|
|
def test_non_prefab_annotation_no_inference(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool
|
|
def my_tool() -> str:
|
|
return "hello"
|
|
|
|
tools = mcp._local_provider._components
|
|
tool = next(
|
|
v
|
|
for v in tools.values()
|
|
if hasattr(v, "parameters") and v.name == "my_tool"
|
|
)
|
|
assert tool.meta is None or "ui" not in (tool.meta or {})
|
|
|
|
def test_optional_prefab_app_inferred(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool
|
|
def my_tool() -> PrefabApp | None:
|
|
return None
|
|
|
|
tools = mcp._local_provider._components
|
|
tool = next(
|
|
v
|
|
for v in tools.values()
|
|
if hasattr(v, "parameters") and v.name == "my_tool"
|
|
)
|
|
assert tool.meta is not None
|
|
assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI
|
|
|
|
def test_annotated_prefab_app_inferred(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool
|
|
def my_tool() -> Annotated[PrefabApp | None, "some metadata"]:
|
|
return None
|
|
|
|
tools = mcp._local_provider._components
|
|
tool = next(
|
|
v
|
|
for v in tools.values()
|
|
if hasattr(v, "parameters") and v.name == "my_tool"
|
|
)
|
|
assert tool.meta is not None
|
|
assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI
|
|
|
|
def test_component_subclass_union_inferred(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool
|
|
def my_tool() -> Column | None:
|
|
return None
|
|
|
|
tools = mcp._local_provider._components
|
|
tool = next(
|
|
v
|
|
for v in tools.values()
|
|
if hasattr(v, "parameters") and v.name == "my_tool"
|
|
)
|
|
assert tool.meta is not None
|
|
assert tool.meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Output schema suppression
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOutputSchema:
|
|
def test_prefab_app_return_no_output_schema(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool
|
|
def my_tool() -> PrefabApp:
|
|
return PrefabApp(view=Heading("hi"))
|
|
|
|
tools = mcp._local_provider._components
|
|
tool: Tool = next(
|
|
v for v in tools.values() if isinstance(v, Tool) and v.name == "my_tool"
|
|
)
|
|
assert tool.output_schema is None
|
|
|
|
def test_component_return_no_output_schema(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool
|
|
def my_tool() -> Column:
|
|
with Column() as view:
|
|
Heading("hi")
|
|
return view
|
|
|
|
tools = mcp._local_provider._components
|
|
tool: Tool = next(
|
|
v for v in tools.values() if isinstance(v, Tool) and v.name == "my_tool"
|
|
)
|
|
assert tool.output_schema is None
|
|
|
|
def test_optional_component_no_output_schema(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool
|
|
def my_tool() -> Column | None:
|
|
return None
|
|
|
|
tools = mcp._local_provider._components
|
|
tool: Tool = next(
|
|
v for v in tools.values() if isinstance(v, Tool) and v.name == "my_tool"
|
|
)
|
|
assert tool.output_schema is None
|
|
|
|
def test_annotated_prefab_app_no_output_schema(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool
|
|
def my_tool() -> Annotated[PrefabApp | None, "metadata"]:
|
|
return None
|
|
|
|
tools = mcp._local_provider._components
|
|
tool: Tool = next(
|
|
v for v in tools.values() if isinstance(v, Tool) and v.name == "my_tool"
|
|
)
|
|
assert tool.output_schema is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration — client-server round trip
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIntegration:
|
|
async def test_tool_call_returns_prefab_structured_content(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(app=True)
|
|
def greet(name: str) -> PrefabApp:
|
|
with Column() as view:
|
|
Heading("Hello")
|
|
Text(f"Welcome, {name}!")
|
|
return PrefabApp(view=view, state={"name": name})
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("greet", {"name": "Alice"})
|
|
|
|
assert result.structured_content is not None
|
|
assert result.structured_content["version"] == "0.2"
|
|
assert result.structured_content["state"] == {"name": "Alice"}
|
|
|
|
async def test_tool_call_with_custom_text(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(app=True)
|
|
def greet(name: str) -> ToolResult:
|
|
app = PrefabApp(view=Heading(f"Hello {name}"))
|
|
return ToolResult(
|
|
content=f"Greeting for {name}",
|
|
structured_content=app,
|
|
)
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("greet", {"name": "Alice"})
|
|
|
|
assert any(
|
|
"Greeting for Alice" in c.text for c in result.content if hasattr(c, "text")
|
|
)
|
|
assert result.structured_content is not None
|
|
assert result.structured_content["version"] == "0.2"
|
|
|
|
async def test_tools_list_includes_app_meta(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(app=True)
|
|
def my_tool() -> PrefabApp:
|
|
return PrefabApp(view=Heading("hi"))
|
|
|
|
async with Client(mcp) as client:
|
|
tools = await client.list_tools()
|
|
|
|
tool = next(t for t in tools if t.name == "my_tool")
|
|
meta = tool.meta or {}
|
|
assert "ui" in meta
|
|
assert meta["ui"]["resourceUri"] == PREFAB_RENDERER_URI
|
|
|
|
async def test_renderer_resource_readable(self):
|
|
mcp = FastMCP("test")
|
|
|
|
@mcp.tool(app=True)
|
|
def my_tool() -> str:
|
|
return "hello"
|
|
|
|
async with Client(mcp) as client:
|
|
contents = await client.read_resource(PREFAB_RENDERER_URI)
|
|
|
|
assert len(contents) > 0
|
|
text = contents[0].text if hasattr(contents[0], "text") else ""
|
|
assert "<html" in text.lower() or "<!doctype" in text.lower()
|