diff --git a/docs/apps/low-level.mdx b/docs/apps/low-level.mdx
index a908dff4b..944e48498 100644
--- a/docs/apps/low-level.mdx
+++ b/docs/apps/low-level.mdx
@@ -1,7 +1,7 @@
---
-title: Low-Level API
-sidebarTitle: Low-Level API
-description: Integrate directly with the MCP Apps extension to build interactive tool UIs.
+title: Custom HTML Apps
+sidebarTitle: Custom HTML
+description: Build apps with your own HTML, CSS, and JavaScript using the MCP Apps extension directly.
icon: code
tag: NEW
---
@@ -10,9 +10,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
-The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) (`io.modelcontextprotocol/ui`) lets tools return interactive UIs — an HTML page rendered in a sandboxed iframe inside the host client. Instead of returning plain text or JSON, a tool can show a chart, a form, an image viewer, or anything you can build with HTML and JavaScript.
+The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) is an open protocol that lets tools return interactive UIs — an HTML page rendered in a sandboxed iframe inside the host client. [Prefab UI](/apps/prefab) builds on this protocol so you never have to think about it, but when you need full control — custom rendering, a specific JavaScript framework, maps, 3D, video — you can use the MCP Apps extension directly.
-This page covers the low-level extension API directly. FastMCP provides typed models for app configuration, automatic `ui://` resource handling, and CSP/permission management.
+This page covers how to write custom HTML apps and wire them up in FastMCP. You'll be working with the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK for host communication, and FastMCP's `AppConfig` for resource and CSP management.
## How It Works
diff --git a/docs/apps/overview.mdx b/docs/apps/overview.mdx
index 07b1d8bc0..f62296200 100644
--- a/docs/apps/overview.mdx
+++ b/docs/apps/overview.mdx
@@ -10,22 +10,63 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
-MCP Apps let your tools return interactive UIs — rendered in a sandboxed iframe right inside the host client's conversation. Instead of returning plain text or JSON, a tool can show a chart, a form, an image viewer, or anything you can build with HTML and JavaScript.
+MCP Apps let your tools return interactive UIs — rendered in a sandboxed iframe right inside the host client's conversation. Instead of returning plain text, a tool can show a chart, a sortable table, a form, or anything you can build with HTML.
-FastMCP implements the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps), so you can start building apps today. FastMCP 3.1 will introduce a full Python-native app framework that makes building rich UIs dramatically simpler — no HTML or JavaScript required.
+FastMCP implements the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) and provides two approaches:
-## What's Available Today
+## Prefab Apps (Recommended)
-FastMCP provides typed models and helpers for working with the MCP Apps extension directly:
+
-- **`AppConfig`** to link tools to UI resources and control visibility
-- **`ui://` resources** that automatically serve HTML with the correct MIME type
-- **`ResourceCSP`** and **`ResourcePermissions`** for security and sandboxing
+[Prefab UI](https://prefab.prefect.io) is a declarative UI framework for Python. You describe layouts, charts, tables, forms, and interactive behaviors using a Python DSL — and the framework compiles them to a JSON protocol that a shared renderer interprets. It started as a component library inside FastMCP and grew into its own framework with [comprehensive documentation](https://prefab.prefect.io).
-This is the [low-level API](/apps/low-level) — you write the HTML yourself and wire up communication with the host via the `@modelcontextprotocol/ext-apps` JavaScript SDK. It gives you full control over the UI.
+```python
+from prefab_ui.components import Column, Heading, BarChart, ChartSeries
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
-## What's Coming in 3.1
+mcp = FastMCP("Dashboard")
-FastMCP 3.1 will ship a Python-native app framework that lets you build interactive UIs entirely in Python. Define layouts, handle events, and manage state without writing any HTML or JavaScript — FastMCP generates the app for you.
+@mcp.tool(app=True)
+def sales_chart(year: int) -> PrefabApp:
+ """Show sales data as an interactive chart."""
+ data = get_sales_data(year)
-Stay tuned. In the meantime, the [low-level API](/apps/low-level) is ready to use.
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(f"{year} Sales")
+ BarChart(
+ data=data,
+ series=[ChartSeries(data_key="revenue", label="Revenue")],
+ x_axis="month",
+ )
+
+ return PrefabApp(view=view)
+```
+
+Install with `pip install "fastmcp[apps]"` and see [Prefab Apps](/apps/prefab) for the integration guide.
+
+## Custom HTML Apps
+
+The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) is an open protocol, and you can use it directly when you need full control. You write your own HTML/CSS/JavaScript and communicate with the host via the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) SDK.
+
+This is the right choice for custom rendering (maps, 3D, video), specific JavaScript frameworks, or capabilities beyond what the component library offers.
+
+```python
+from fastmcp import FastMCP
+from fastmcp.server.apps import AppConfig, ResourceCSP
+
+mcp = FastMCP("Custom App")
+
+@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html"))
+def my_tool() -> str:
+ return '{"values": [1, 2, 3]}'
+
+@mcp.resource(
+ "ui://my-app/view.html",
+ app=AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"])),
+)
+def view() -> str:
+ return "..."
+```
+
+See [Custom HTML Apps](/apps/low-level) for the full reference.
diff --git a/docs/apps/patterns.mdx b/docs/apps/patterns.mdx
new file mode 100644
index 000000000..714d7e247
--- /dev/null
+++ b/docs/apps/patterns.mdx
@@ -0,0 +1,479 @@
+---
+title: Patterns
+sidebarTitle: Patterns
+description: Charts, tables, forms, and other common tool UIs.
+icon: grid-2-plus
+tag: SOON
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+The most common use of Prefab is giving your tools a visual representation — a chart instead of raw numbers, a sortable table instead of a text dump, a status dashboard instead of a list of booleans. Each pattern below is a complete, copy-pasteable tool.
+
+## Charts
+
+Prefab includes [bar, line, area, pie, radar, and radial charts](https://prefab.prefect.io/docs/components/charts). They all render client-side with tooltips, legends, and responsive sizing.
+
+### Bar Chart
+
+```python
+from prefab_ui.components import Column, Heading, BarChart, ChartSeries
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Charts")
+
+
+@mcp.tool(app=True)
+def quarterly_revenue(year: int) -> PrefabApp:
+ """Show quarterly revenue as a bar chart."""
+ data = [
+ {"quarter": "Q1", "revenue": 42000, "costs": 28000},
+ {"quarter": "Q2", "revenue": 51000, "costs": 31000},
+ {"quarter": "Q3", "revenue": 47000, "costs": 29000},
+ {"quarter": "Q4", "revenue": 63000, "costs": 35000},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(f"{year} Revenue vs Costs")
+ BarChart(
+ data=data,
+ series=[
+ ChartSeries(data_key="revenue", label="Revenue"),
+ ChartSeries(data_key="costs", label="Costs"),
+ ],
+ x_axis="quarter",
+ show_legend=True,
+ )
+
+ return PrefabApp(view=view)
+```
+
+Multiple `ChartSeries` entries plot different data keys. Add `stacked=True` to stack bars, or `horizontal=True` to flip the axes.
+
+### Area Chart
+
+`LineChart` and `AreaChart` share the same API as `BarChart`, with `curve` for interpolation (`"linear"`, `"smooth"`, `"step"`) and `show_dots` for data points:
+
+```python
+from prefab_ui.components import Column, Heading, AreaChart, ChartSeries
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Charts")
+
+
+@mcp.tool(app=True)
+def usage_trend() -> PrefabApp:
+ """Show API usage over time."""
+ data = [
+ {"date": "Feb 1", "requests": 1200},
+ {"date": "Feb 2", "requests": 1350},
+ {"date": "Feb 3", "requests": 980},
+ {"date": "Feb 4", "requests": 1500},
+ {"date": "Feb 5", "requests": 1420},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("API Usage")
+ AreaChart(
+ data=data,
+ series=[ChartSeries(data_key="requests", label="Requests")],
+ x_axis="date",
+ curve="smooth",
+ height=250,
+ )
+
+ return PrefabApp(view=view)
+```
+
+### Pie and Donut Charts
+
+`PieChart` uses `data_key` (the numeric value) and `name_key` (the label) instead of series. Set `inner_radius` for a donut:
+
+```python
+from prefab_ui.components import Column, Heading, PieChart
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Charts")
+
+
+@mcp.tool(app=True)
+def ticket_breakdown() -> PrefabApp:
+ """Show open tickets by category."""
+ data = [
+ {"category": "Bug", "count": 23},
+ {"category": "Feature", "count": 15},
+ {"category": "Docs", "count": 8},
+ {"category": "Infra", "count": 12},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Open Tickets")
+ PieChart(
+ data=data,
+ data_key="count",
+ name_key="category",
+ show_legend=True,
+ inner_radius=60,
+ )
+
+ return PrefabApp(view=view)
+```
+
+## Data Tables
+
+[DataTable](https://prefab.prefect.io/docs/components/data-display/data-table) provides sortable columns, full-text search, and pagination — all running client-side in the browser.
+
+```python
+from prefab_ui.components import Column, Heading, DataTable, DataTableColumn
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Directory")
+
+
+@mcp.tool(app=True)
+def employee_directory() -> PrefabApp:
+ """Show a searchable, sortable employee directory."""
+ employees = [
+ {"name": "Alice Chen", "department": "Engineering", "role": "Staff Engineer", "location": "SF"},
+ {"name": "Bob Martinez", "department": "Design", "role": "Lead Designer", "location": "NYC"},
+ {"name": "Carol Johnson", "department": "Engineering", "role": "Senior Engineer", "location": "London"},
+ {"name": "David Kim", "department": "Product", "role": "Product Manager", "location": "SF"},
+ {"name": "Eva Müller", "department": "Engineering", "role": "Engineer", "location": "Berlin"},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Employee Directory")
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="department", header="Department", sortable=True),
+ DataTableColumn(key="role", header="Role"),
+ DataTableColumn(key="location", header="Office", sortable=True),
+ ],
+ rows=employees,
+ searchable=True,
+ paginated=True,
+ page_size=15,
+ )
+
+ return PrefabApp(view=view)
+```
+
+## Forms
+
+A form collects input, but it needs somewhere to send that input. The [`CallTool`](https://prefab.prefect.io/docs/concepts/actions) action connects a form to a tool on your MCP server — so you need two tools: one that renders the form, and one that handles the submission.
+
+```python
+from prefab_ui.components import (
+ Column, Heading, Row, Muted, Badge, Input, Select,
+ Textarea, Button, Form, ForEach, Separator,
+)
+from prefab_ui.actions import ShowToast
+from prefab_ui.actions.mcp import CallTool
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Contacts")
+
+contacts_db: list[dict] = [
+ {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Partner"},
+]
+
+
+@mcp.tool(app=True)
+def contact_form() -> PrefabApp:
+ """Show a contact list with a form to add new contacts."""
+ with Column(gap=6, css_class="p-6") as view:
+ Heading("Contacts")
+
+ with ForEach("contacts"):
+ with Row(gap=2, align="center"):
+ Muted("{{ name }}")
+ Muted("{{ email }}")
+ Badge("{{ category }}")
+
+ Separator()
+
+ with Form(
+ on_submit=CallTool(
+ "save_contact",
+ result_key="contacts",
+ on_success=ShowToast("Contact saved!", variant="success"),
+ on_error=ShowToast("{{ $error }}", variant="error"),
+ )
+ ):
+ Input(name="name", label="Full Name", required=True)
+ Input(name="email", label="Email", input_type="email", required=True)
+ Select(
+ name="category",
+ label="Category",
+ options=["Customer", "Vendor", "Partner", "Other"],
+ )
+ Textarea(name="notes", label="Notes", placeholder="Optional notes...")
+ Button("Save Contact")
+
+ return PrefabApp(view=view, state={"contacts": list(contacts_db)})
+
+
+@mcp.tool
+def save_contact(
+ name: str,
+ email: str,
+ category: str = "Other",
+ notes: str = "",
+) -> list[dict]:
+ """Save a new contact and return the updated list."""
+ contacts_db.append({"name": name, "email": email, "category": category, "notes": notes})
+ return list(contacts_db)
+```
+
+When the user submits the form, the renderer calls `save_contact` on the server with all named input values as arguments. Because `result_key="contacts"` is set, the returned list replaces the `contacts` state — and the `ForEach` re-renders with the new data automatically.
+
+The `save_contact` tool is a regular MCP tool. The LLM can also call it directly in conversation. Your UI actions and your conversational tools are the same thing.
+
+### Pydantic Model Forms
+
+For complex forms, `Form.from_model()` generates the entire form from a Pydantic model — inputs, labels, validation, and submit wiring:
+
+```python
+from typing import Literal
+
+from pydantic import BaseModel, Field
+from prefab_ui.components import Column, Heading, Form
+from prefab_ui.actions.mcp import CallTool
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Bug Tracker")
+
+
+class BugReport(BaseModel):
+ title: str = Field(title="Bug Title")
+ severity: Literal["low", "medium", "high", "critical"] = Field(
+ title="Severity", default="medium"
+ )
+ description: str = Field(title="Description")
+ steps_to_reproduce: str = Field(title="Steps to Reproduce")
+
+
+@mcp.tool(app=True)
+def report_bug() -> PrefabApp:
+ """Show a bug report form."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Report a Bug")
+ Form.from_model(BugReport, on_submit=CallTool("create_bug_report"))
+
+ return PrefabApp(view=view)
+
+
+@mcp.tool
+def create_bug_report(data: dict) -> str:
+ """Create a bug report from the form submission."""
+ report = BugReport(**data)
+ # save to database...
+ return f"Created bug report: {report.title}"
+```
+
+`str` fields become text inputs, `Literal` becomes a select, `bool` becomes a checkbox. The `on_submit` CallTool receives all field values under a `data` key.
+
+## Status Displays
+
+Cards, badges, progress bars, and grids combine naturally for dashboards. See the [Prefab layout](https://prefab.prefect.io/docs/concepts/composition) and [container](https://prefab.prefect.io/docs/components/containers) docs for the full set of layout and display components.
+
+```python
+from prefab_ui.components import (
+ Column, Row, Grid, Heading, Text, Muted, Badge,
+ Card, CardContent, Progress, Separator,
+)
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Monitoring")
+
+
+@mcp.tool(app=True)
+def system_status() -> PrefabApp:
+ """Show current system health."""
+ services = [
+ {"name": "API Gateway", "status": "healthy", "ok": True, "latency_ms": 12, "uptime_pct": 99.9},
+ {"name": "Database", "status": "healthy", "ok": True, "latency_ms": 3, "uptime_pct": 99.99},
+ {"name": "Cache", "status": "degraded", "ok": False, "latency_ms": 45, "uptime_pct": 98.2},
+ {"name": "Queue", "status": "healthy", "ok": True, "latency_ms": 8, "uptime_pct": 99.8},
+ ]
+ all_ok = all(s["ok"] for s in services)
+
+ with Column(gap=4, css_class="p-6") as view:
+ with Row(gap=2, align="center"):
+ Heading("System Status")
+ Badge(
+ "All Healthy" if all_ok else "Degraded",
+ variant="success" if all_ok else "destructive",
+ )
+
+ Separator()
+
+ with Grid(columns=2, gap=4):
+ for svc in services:
+ with Card():
+ with CardContent():
+ with Row(gap=2, align="center"):
+ Text(svc["name"], css_class="font-medium")
+ Badge(
+ svc["status"],
+ variant="success" if svc["ok"] else "destructive",
+ )
+ Muted(f"Response: {svc['latency_ms']}ms")
+ Progress(value=svc["uptime_pct"])
+
+ return PrefabApp(view=view)
+```
+
+## Conditional Content
+
+[`If`, `Elif`, and `Else`](https://prefab.prefect.io/docs/concepts/composition#conditional-rendering) show or hide content based on state. Changes are instant — no server round-trip.
+
+```python
+from prefab_ui.components import Column, Heading, Switch, Separator, Alert, If
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Flags")
+
+
+@mcp.tool(app=True)
+def feature_flags() -> PrefabApp:
+ """Toggle feature flags with live preview."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Feature Flags")
+
+ Switch(name="dark_mode", label="Dark Mode")
+ Switch(name="beta_features", label="Beta Features")
+
+ Separator()
+
+ with If("{{ dark_mode }}"):
+ Alert(title="Dark mode enabled", description="UI will use dark theme.")
+ with If("{{ beta_features }}"):
+ Alert(
+ title="Beta features active",
+ description="Experimental features are now visible.",
+ variant="warning",
+ )
+
+ return PrefabApp(view=view, state={"dark_mode": False, "beta_features": False})
+```
+
+## Tabs
+
+[Tabs](https://prefab.prefect.io/docs/components/containers/tabs) organize content into switchable views. Switching is client-side — no server round-trip.
+
+```python
+from prefab_ui.components import (
+ Column, Heading, Text, Muted, Badge, Row,
+ DataTable, DataTableColumn, Tabs, Tab, ForEach,
+)
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Projects")
+
+
+@mcp.tool(app=True)
+def project_overview(project_id: str) -> PrefabApp:
+ """Show project details organized in tabs."""
+ project = {
+ "name": "FastMCP v3",
+ "description": "Next generation MCP framework with Apps support.",
+ "status": "Active",
+ "created_at": "2025-01-15",
+ "members": [
+ {"name": "Alice Chen", "role": "Lead"},
+ {"name": "Bob Martinez", "role": "Design"},
+ ],
+ "activity": [
+ {"timestamp": "2 hours ago", "message": "Merged PR #342"},
+ {"timestamp": "1 day ago", "message": "Released v3.0.1"},
+ ],
+ }
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(project["name"])
+
+ with Tabs():
+ with Tab("Overview"):
+ Text(project["description"])
+ with Row(gap=4):
+ Badge(project["status"])
+ Muted(f"Created: {project['created_at']}")
+
+ with Tab("Members"):
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="role", header="Role"),
+ ],
+ rows=project["members"],
+ )
+
+ with Tab("Activity"):
+ with ForEach("activity"):
+ with Row(gap=2):
+ Muted("{{ timestamp }}")
+ Text("{{ message }}")
+
+ return PrefabApp(view=view, state={"activity": project["activity"]})
+```
+
+## Accordion
+
+[Accordion](https://prefab.prefect.io/docs/components/containers/accordion) collapses sections to save space. `multiple=True` lets users expand several items at once:
+
+```python
+from prefab_ui.components import (
+ Column, Heading, Row, Text, Badge, Progress,
+ Accordion, AccordionItem,
+)
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("API Monitor")
+
+
+@mcp.tool(app=True)
+def api_health() -> PrefabApp:
+ """Show health details for each API endpoint."""
+ endpoints = [
+ {"path": "/api/users", "status": 200, "healthy": True, "avg_ms": 45, "p99_ms": 120, "uptime_pct": 99.9},
+ {"path": "/api/orders", "status": 200, "healthy": True, "avg_ms": 82, "p99_ms": 250, "uptime_pct": 99.7},
+ {"path": "/api/search", "status": 200, "healthy": True, "avg_ms": 150, "p99_ms": 500, "uptime_pct": 99.5},
+ {"path": "/api/webhooks", "status": 503, "healthy": False, "avg_ms": 2000, "p99_ms": 5000, "uptime_pct": 95.1},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("API Health")
+
+ with Accordion(multiple=True):
+ for ep in endpoints:
+ with AccordionItem(ep["path"]):
+ with Row(gap=4):
+ Badge(
+ f"{ep['status']}",
+ variant="success" if ep["healthy"] else "destructive",
+ )
+ Text(f"Avg: {ep['avg_ms']}ms")
+ Text(f"P99: {ep['p99_ms']}ms")
+ Progress(value=ep["uptime_pct"])
+
+ return PrefabApp(view=view)
+```
+
+## Next Steps
+
+- **[Custom HTML Apps](/apps/low-level)** — When you need your own HTML, CSS, and JavaScript
+- **[Prefab UI Docs](https://prefab.prefect.io)** — Components, state, expressions, and actions
diff --git a/docs/apps/prefab.mdx b/docs/apps/prefab.mdx
new file mode 100644
index 000000000..88927a23a
--- /dev/null
+++ b/docs/apps/prefab.mdx
@@ -0,0 +1,191 @@
+---
+title: Prefab Apps
+sidebarTitle: Prefab Apps
+description: Build interactive tool UIs in pure Python — no HTML or JavaScript required.
+icon: palette
+tag: SOON
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+[Prefab UI](https://prefab.prefect.io) is a declarative UI framework for Python. You describe what your interface should look like — a chart, a table, a form — and return it from your tool. FastMCP takes care of everything else: registering the renderer, wiring the protocol metadata, and delivering the component tree to the host.
+
+Prefab started as a component library inside FastMCP and grew into a full framework for building interactive applications — with its own state management, reactive expression system, and action model. The [Prefab documentation](https://prefab.prefect.io) covers all of this in depth. This page focuses on the FastMCP integration: what you return from a tool, and what FastMCP does with it.
+
+```bash
+pip install "fastmcp[apps]"
+```
+
+
+Prefab UI is in active early development and its API changes frequently. We strongly recommend pinning `prefab-ui` to a specific version in your project's dependencies. Installing `fastmcp[apps]` pulls in `prefab-ui` but won't pin it — so a routine `pip install --upgrade` could introduce breaking changes.
+
+```toml
+# pyproject.toml
+dependencies = [
+ "fastmcp[apps]",
+ "prefab-ui==0.8.0", # pin to a known working version
+]
+```
+
+
+Here's the simplest possible Prefab App — a tool that returns a bar chart:
+
+```python
+from prefab_ui.components import Column, Heading, BarChart, ChartSeries
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Dashboard")
+
+
+@mcp.tool(app=True)
+def revenue_chart(year: int) -> PrefabApp:
+ """Show annual revenue as an interactive bar chart."""
+ data = [
+ {"quarter": "Q1", "revenue": 42000},
+ {"quarter": "Q2", "revenue": 51000},
+ {"quarter": "Q3", "revenue": 47000},
+ {"quarter": "Q4", "revenue": 63000},
+ ]
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(f"{year} Revenue")
+ BarChart(
+ data=data,
+ series=[ChartSeries(data_key="revenue", label="Revenue")],
+ x_axis="quarter",
+ )
+
+ return PrefabApp(view=view)
+```
+
+That's it — you declare a layout using Python's `with` statement, and return it. When the host calls this tool, the user sees an interactive bar chart instead of a JSON blob. The [Patterns](/apps/patterns) page has more examples: area charts, data tables, forms, status dashboards, and more.
+
+## What You Return
+
+### Components
+
+The simplest way to get started. If you're returning a visual representation of data and don't need Prefab's more advanced features like initial state or stylesheets, just return the components directly. FastMCP wraps them in a `PrefabApp` automatically:
+
+```python
+from prefab_ui.components import Column, Heading, Badge
+from fastmcp import FastMCP
+
+mcp = FastMCP("Status")
+
+
+@mcp.tool(app=True)
+def status_badge() -> Column:
+ """Show system status."""
+ with Column(gap=2) as view:
+ Heading("All Systems Operational")
+ Badge("Healthy", variant="success")
+ return view
+```
+
+Want a chart? Return a chart. Want a table? Return a table. FastMCP handles the wiring.
+
+### PrefabApp
+
+When you need more control — setting initial state values that components can read and react to, or configuring the rendering engine — return a `PrefabApp` explicitly:
+
+```python
+from prefab_ui.components import Column, Heading, Text, Button, If, Badge
+from prefab_ui.actions import ToggleState
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+
+mcp = FastMCP("Demo")
+
+
+@mcp.tool(app=True)
+def toggle_demo() -> PrefabApp:
+ """Interactive toggle with state."""
+ with Column(gap=4, css_class="p-6") as view:
+ Button("Toggle", on_click=ToggleState("show"))
+ with If("{{ show }}"):
+ Badge("Visible!", variant="success")
+
+ return PrefabApp(view=view, state={"show": False})
+```
+
+The `state` dict provides the initial values. Components reference state with `{{ expression }}` templates. State mutations like `ToggleState` happen entirely in the browser — no server round-trip. The [Prefab state guide](https://prefab.prefect.io/docs/concepts/state) covers this in detail.
+
+### ToolResult
+
+Every tool result has two audiences: the renderer (which displays the UI) and the LLM (which reads the text content to understand what happened). By default, Prefab Apps send `"[Rendered Prefab UI]"` as the text content, which tells the LLM almost nothing.
+
+If you want the LLM to understand the result — so it can reference the data in conversation, summarize it, or decide what to do next — wrap your return in a `ToolResult` with a meaningful `content` string:
+
+```python
+from prefab_ui.components import Column, Heading, BarChart, ChartSeries
+from prefab_ui.app import PrefabApp
+from fastmcp import FastMCP
+from fastmcp.tools import ToolResult
+
+mcp = FastMCP("Sales")
+
+
+@mcp.tool(app=True)
+def sales_overview(year: int) -> ToolResult:
+ """Show sales data visually and summarize for the model."""
+ data = get_sales_data(year)
+ total = sum(row["revenue"] for row in data)
+
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Sales Overview")
+ BarChart(data=data, series=[ChartSeries(data_key="revenue")])
+
+ return ToolResult(
+ content=f"Total revenue for {year}: ${total:,} across {len(data)} quarters",
+ structured_content=view,
+ )
+```
+
+The user sees the chart. The LLM sees `"Total revenue for 2025: $203,000 across 4 quarters"` and can reason about it.
+
+## Type Inference
+
+If your tool's return type annotation is a Prefab type — `PrefabApp`, `Component`, or their `Optional` variants — FastMCP detects this and enables app rendering automatically:
+
+```python
+@mcp.tool
+def greet(name: str) -> PrefabApp:
+ return PrefabApp(view=Heading(f"Hello, {name}!"))
+```
+
+This is equivalent to `@mcp.tool(app=True)`. Explicit `app=True` is recommended for clarity, and is required when the return type doesn't reveal a Prefab type (e.g., `-> ToolResult`).
+
+## How It Works
+
+Behind the scenes, when a tool returns a Prefab component or `PrefabApp`, FastMCP:
+
+1. **Registers a shared renderer** — a `ui://prefab/renderer.html` resource containing the JavaScript rendering engine, fetched once by the host and reused across all your Prefab tools.
+2. **Wires the tool metadata** — so the host knows to load the renderer iframe when displaying the tool result.
+3. **Serializes the component tree** — your Python components become `structuredContent` on the tool result, which the renderer interprets and displays.
+
+None of this requires any configuration. The `app=True` flag (or type inference) is the only thing you need.
+
+## Mixing with Custom HTML Apps
+
+Prefab tools and [custom HTML tools](/apps/low-level) coexist in the same server. Prefab tools share a single renderer resource; custom tools point to their own. Both use the same MCP Apps protocol:
+
+```python
+from fastmcp.server.apps import AppConfig
+
+@mcp.tool(app=True)
+def team_directory() -> PrefabApp:
+ ...
+
+@mcp.tool(app=AppConfig(resource_uri="ui://my-app/map.html"))
+def map_view() -> str:
+ ...
+```
+
+## Next Steps
+
+- **[Patterns](/apps/patterns)** — Charts, tables, forms, and other common tool UIs
+- **[Custom HTML Apps](/apps/low-level)** — When you need your own HTML, CSS, and JavaScript
+- **[Prefab UI Docs](https://prefab.prefect.io)** — Components, state, expressions, and actions
diff --git a/docs/css/style.css b/docs/css/style.css
index 8c484ce96..99844f692 100644
--- a/docs/css/style.css
+++ b/docs/css/style.css
@@ -1,3 +1,7 @@
+html:not([data-page-mode="wide"]) #content-area {
+ max-width: 44rem !important;
+}
+
img.nav-logo {
max-width: 200px;
}
diff --git a/docs/docs.json b/docs/docs.json
index 24422f592..66380d498 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -202,6 +202,8 @@
"group": "Apps",
"pages": [
"apps/overview",
+ "apps/prefab",
+ "apps/patterns",
"apps/low-level"
]
},
diff --git a/examples/apps/chart_server.py b/examples/apps/chart_server.py
new file mode 100644
index 000000000..94130f777
--- /dev/null
+++ b/examples/apps/chart_server.py
@@ -0,0 +1,102 @@
+"""Chart MCP App — interactive data visualizations with Prefab.
+
+Demonstrates `fastmcp[apps]` with Prefab chart components:
+- `BarChart` and `LineChart` for categorical and trend data
+- Multiple series, stacking, and curve styles
+- Layout composition with `Column`, `Heading`, and `Muted`
+- Custom text fallback via `ToolResult`
+
+Usage:
+ uv run python chart_server.py # HTTP (port 8000)
+ uv run python chart_server.py --stdio # stdio for MCP clients
+"""
+
+from __future__ import annotations
+
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import (
+ BarChart,
+ ChartSeries,
+ Column,
+ Heading,
+ LineChart,
+ Muted,
+)
+
+from fastmcp import FastMCP
+
+mcp = FastMCP("Sales Dashboard")
+
+MONTHLY_SALES = [
+ {"month": "Jan", "online": 4200, "retail": 2400},
+ {"month": "Feb", "online": 3800, "retail": 2100},
+ {"month": "Mar", "online": 5100, "retail": 2800},
+ {"month": "Apr", "online": 4600, "retail": 3200},
+ {"month": "May", "online": 5800, "retail": 3100},
+ {"month": "Jun", "online": 6200, "retail": 3500},
+]
+
+
+@mcp.tool(app=True)
+def sales_overview(stacked: bool = False) -> PrefabApp:
+ """View monthly sales broken down by channel.
+
+ Args:
+ stacked: Stack bars to show total revenue per month.
+ """
+ total = sum(row["online"] + row["retail"] for row in MONTHLY_SALES)
+
+ with Column(gap=6, css_class="p-6") as view:
+ with Column(gap=1):
+ Heading("Monthly Sales")
+ Muted(f"${total:,} total revenue")
+
+ BarChart(
+ data=MONTHLY_SALES,
+ series=[
+ ChartSeries(data_key="online", label="Online"),
+ ChartSeries(data_key="retail", label="Retail"),
+ ],
+ x_axis="month",
+ stacked=stacked,
+ show_legend=True,
+ )
+
+ return PrefabApp(
+ title="Sales Dashboard",
+ view=view,
+ )
+
+
+@mcp.tool(app=True)
+def sales_trend(curve: str = "linear") -> PrefabApp:
+ """View sales trends over time as a line chart.
+
+ Args:
+ curve: Line style — "linear", "smooth", or "step".
+ """
+ with Column(gap=6, css_class="p-6") as view:
+ with Column(gap=1):
+ Heading("Sales Trend")
+ Muted("Online vs. retail over 6 months")
+
+ LineChart(
+ data=MONTHLY_SALES,
+ series=[
+ ChartSeries(data_key="online", label="Online"),
+ ChartSeries(data_key="retail", label="Retail"),
+ ],
+ x_axis="month",
+ curve=curve,
+ show_dots=True,
+ show_legend=True,
+ )
+
+ return PrefabApp(
+ title="Sales Trend",
+ view=view,
+ )
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/examples/apps/datatable_server.py b/examples/apps/datatable_server.py
new file mode 100644
index 000000000..1f79c51dd
--- /dev/null
+++ b/examples/apps/datatable_server.py
@@ -0,0 +1,165 @@
+"""DataTable MCP App — interactive, sortable data views with Prefab.
+
+Demonstrates `fastmcp[apps]` with Prefab UI components:
+- `app=True` for automatic renderer wiring
+- `PrefabApp` with `DataTable` for rich tabular views
+- Searchable, sortable, paginated tables
+- Layout composition with `Column`, `Heading`, `Text`, and `Badge`
+
+Usage:
+ uv run python datatable_server.py # HTTP (port 8000)
+ uv run python datatable_server.py --stdio # stdio for MCP clients
+"""
+
+from __future__ import annotations
+
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import (
+ Badge,
+ Column,
+ DataTable,
+ DataTableColumn,
+ Heading,
+ Muted,
+ Row,
+)
+
+from fastmcp import FastMCP
+
+mcp = FastMCP("Team Directory")
+
+EMPLOYEES = [
+ {
+ "name": "Alice Chen",
+ "role": "Engineering",
+ "level": "Senior",
+ "location": "San Francisco",
+ "status": "active",
+ },
+ {
+ "name": "Bob Martinez",
+ "role": "Design",
+ "level": "Lead",
+ "location": "New York",
+ "status": "active",
+ },
+ {
+ "name": "Carol Johnson",
+ "role": "Engineering",
+ "level": "Staff",
+ "location": "London",
+ "status": "active",
+ },
+ {
+ "name": "David Kim",
+ "role": "Product",
+ "level": "Senior",
+ "location": "San Francisco",
+ "status": "away",
+ },
+ {
+ "name": "Eva Müller",
+ "role": "Engineering",
+ "level": "Mid",
+ "location": "Berlin",
+ "status": "active",
+ },
+ {
+ "name": "Frank Okafor",
+ "role": "Data Science",
+ "level": "Senior",
+ "location": "Lagos",
+ "status": "active",
+ },
+ {
+ "name": "Grace Liu",
+ "role": "Engineering",
+ "level": "Junior",
+ "location": "Singapore",
+ "status": "active",
+ },
+ {
+ "name": "Hassan Ali",
+ "role": "Design",
+ "level": "Senior",
+ "location": "Dubai",
+ "status": "away",
+ },
+ {
+ "name": "Iris Tanaka",
+ "role": "Product",
+ "level": "Lead",
+ "location": "Tokyo",
+ "status": "active",
+ },
+ {
+ "name": "James Wright",
+ "role": "Engineering",
+ "level": "Senior",
+ "location": "London",
+ "status": "inactive",
+ },
+ {
+ "name": "Karen Petrov",
+ "role": "Data Science",
+ "level": "Lead",
+ "location": "Berlin",
+ "status": "active",
+ },
+ {
+ "name": "Liam O'Brien",
+ "role": "Engineering",
+ "level": "Mid",
+ "location": "Dublin",
+ "status": "active",
+ },
+]
+
+
+@mcp.tool(app=True)
+def list_team(department: str | None = None) -> PrefabApp:
+ """Browse the team directory with sorting and search.
+
+ Args:
+ department: Filter by department (e.g. "Engineering", "Design").
+ Leave empty to show everyone.
+ """
+ if department:
+ rows = [e for e in EMPLOYEES if e["role"].lower() == department.lower()]
+ else:
+ rows = EMPLOYEES
+
+ active = sum(1 for e in rows if e["status"] == "active")
+
+ with Column(gap=6, css_class="p-6") as view:
+ with Column(gap=1):
+ Heading("Team Directory")
+ with Row(gap=2):
+ Muted(f"{len(rows)} members")
+ Muted(f"{active} active", css_class="text-success")
+ if department:
+ Badge(department, variant="outline")
+
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="role", header="Department", sortable=True),
+ DataTableColumn(key="level", header="Level", sortable=True),
+ DataTableColumn(key="location", header="Location", sortable=True),
+ DataTableColumn(key="status", header="Status", sortable=True),
+ ],
+ rows=rows,
+ searchable=True,
+ paginated=True,
+ page_size=10,
+ )
+
+ return PrefabApp(
+ title="Team Directory",
+ view=view,
+ state={"total": len(rows), "active": active},
+ )
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/examples/apps/patterns_server.py b/examples/apps/patterns_server.py
new file mode 100644
index 000000000..9b5b8ac79
--- /dev/null
+++ b/examples/apps/patterns_server.py
@@ -0,0 +1,489 @@
+"""Patterns showcase — every Prefab pattern from the docs in one server.
+
+A runnable collection of the patterns from https://gofastmcp.com/apps/patterns.
+Each tool demonstrates a different Prefab UI pattern: charts, tables, forms,
+status displays, conditional content, tabs, and accordions.
+
+Usage:
+ uv run python patterns_server.py # HTTP (port 8000)
+ uv run python patterns_server.py --stdio # stdio for MCP clients
+"""
+
+from __future__ import annotations
+
+from prefab_ui.actions import ShowToast
+from prefab_ui.actions.mcp import CallTool
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import (
+ Accordion,
+ AccordionItem,
+ Alert,
+ AreaChart,
+ Badge,
+ BarChart,
+ Button,
+ Card,
+ CardContent,
+ ChartSeries,
+ Column,
+ DataTable,
+ DataTableColumn,
+ ForEach,
+ Form,
+ Grid,
+ Heading,
+ If,
+ Input,
+ Muted,
+ PieChart,
+ Progress,
+ Row,
+ Select,
+ Separator,
+ Switch,
+ Tab,
+ Tabs,
+ Text,
+ Textarea,
+)
+
+from fastmcp import FastMCP
+
+mcp = FastMCP("Patterns Showcase")
+
+
+# ---------------------------------------------------------------------------
+# Data
+# ---------------------------------------------------------------------------
+
+QUARTERLY_DATA = [
+ {"quarter": "Q1", "revenue": 42000, "costs": 28000},
+ {"quarter": "Q2", "revenue": 51000, "costs": 31000},
+ {"quarter": "Q3", "revenue": 47000, "costs": 29000},
+ {"quarter": "Q4", "revenue": 63000, "costs": 35000},
+]
+
+DAILY_USAGE = [
+ {"date": f"Feb {d}", "requests": v}
+ for d, v in zip(
+ range(1, 11),
+ [1200, 1350, 980, 1500, 1420, 1680, 1550, 1700, 1450, 1600],
+ )
+]
+
+TICKETS = [
+ {"category": "Bug", "count": 23},
+ {"category": "Feature", "count": 15},
+ {"category": "Docs", "count": 8},
+ {"category": "Infra", "count": 12},
+]
+
+EMPLOYEES = [
+ {
+ "name": "Alice Chen",
+ "department": "Engineering",
+ "role": "Staff Engineer",
+ "location": "San Francisco",
+ },
+ {
+ "name": "Bob Martinez",
+ "department": "Design",
+ "role": "Lead Designer",
+ "location": "New York",
+ },
+ {
+ "name": "Carol Johnson",
+ "department": "Engineering",
+ "role": "Senior Engineer",
+ "location": "London",
+ },
+ {
+ "name": "David Kim",
+ "department": "Product",
+ "role": "Product Manager",
+ "location": "San Francisco",
+ },
+ {
+ "name": "Eva Müller",
+ "department": "Engineering",
+ "role": "Engineer",
+ "location": "Berlin",
+ },
+ {
+ "name": "Frank Okafor",
+ "department": "Data Science",
+ "role": "Senior Analyst",
+ "location": "Lagos",
+ },
+ {
+ "name": "Grace Liu",
+ "department": "Engineering",
+ "role": "Junior Engineer",
+ "location": "Singapore",
+ },
+ {
+ "name": "Hassan Ali",
+ "department": "Design",
+ "role": "Senior Designer",
+ "location": "Dubai",
+ },
+]
+
+SERVICES = [
+ {
+ "name": "API Gateway",
+ "status": "healthy",
+ "ok": True,
+ "latency_ms": 12,
+ "uptime_pct": 99.9,
+ },
+ {
+ "name": "Database",
+ "status": "healthy",
+ "ok": True,
+ "latency_ms": 3,
+ "uptime_pct": 99.99,
+ },
+ {
+ "name": "Cache",
+ "status": "degraded",
+ "ok": False,
+ "latency_ms": 45,
+ "uptime_pct": 98.2,
+ },
+ {
+ "name": "Queue",
+ "status": "healthy",
+ "ok": True,
+ "latency_ms": 8,
+ "uptime_pct": 99.8,
+ },
+]
+
+ENDPOINTS = [
+ {
+ "path": "/api/users",
+ "status": 200,
+ "healthy": True,
+ "avg_ms": 45,
+ "p99_ms": 120,
+ "uptime_pct": 99.9,
+ },
+ {
+ "path": "/api/orders",
+ "status": 200,
+ "healthy": True,
+ "avg_ms": 82,
+ "p99_ms": 250,
+ "uptime_pct": 99.7,
+ },
+ {
+ "path": "/api/search",
+ "status": 200,
+ "healthy": True,
+ "avg_ms": 150,
+ "p99_ms": 500,
+ "uptime_pct": 99.5,
+ },
+ {
+ "path": "/api/webhooks",
+ "status": 503,
+ "healthy": False,
+ "avg_ms": 2000,
+ "p99_ms": 5000,
+ "uptime_pct": 95.1,
+ },
+]
+
+PROJECT = {
+ "name": "FastMCP v3",
+ "description": "Next generation MCP framework with Apps support.",
+ "status": "Active",
+ "created_at": "2025-01-15",
+ "members": [
+ {"name": "Alice Chen", "role": "Lead"},
+ {"name": "Bob Martinez", "role": "Design"},
+ {"name": "Carol Johnson", "role": "Backend"},
+ ],
+ "activity": [
+ {
+ "timestamp": "2 hours ago",
+ "message": "Merged PR #342: Add Prefab UI integration",
+ },
+ {
+ "timestamp": "5 hours ago",
+ "message": "Opened issue #345: CORS convenience API",
+ },
+ {"timestamp": "1 day ago", "message": "Released v3.0.1"},
+ ],
+}
+
+# In-memory contact store for the form demo
+_contacts: list[dict] = [
+ {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Partner"},
+]
+
+
+# ---------------------------------------------------------------------------
+# Charts
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def quarterly_revenue(year: int = 2025) -> PrefabApp:
+ """Show quarterly revenue as a bar chart."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(f"{year} Revenue vs Costs")
+ BarChart(
+ data=QUARTERLY_DATA,
+ series=[
+ ChartSeries(data_key="revenue", label="Revenue"),
+ ChartSeries(data_key="costs", label="Costs"),
+ ],
+ x_axis="quarter",
+ show_legend=True,
+ )
+
+ return PrefabApp(view=view)
+
+
+@mcp.tool(app=True)
+def usage_trend() -> PrefabApp:
+ """Show API usage over time as an area chart."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("API Usage (10 Days)")
+ AreaChart(
+ data=DAILY_USAGE,
+ series=[ChartSeries(data_key="requests", label="Requests")],
+ x_axis="date",
+ curve="smooth",
+ height=250,
+ )
+
+ return PrefabApp(view=view)
+
+
+@mcp.tool(app=True)
+def ticket_breakdown() -> PrefabApp:
+ """Show open tickets by category as a donut chart."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Open Tickets")
+ PieChart(
+ data=TICKETS,
+ data_key="count",
+ name_key="category",
+ show_legend=True,
+ inner_radius=60,
+ )
+
+ return PrefabApp(view=view)
+
+
+# ---------------------------------------------------------------------------
+# Data Tables
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def employee_directory() -> PrefabApp:
+ """Show a searchable, sortable employee directory."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Employee Directory")
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="department", header="Department", sortable=True),
+ DataTableColumn(key="role", header="Role"),
+ DataTableColumn(key="location", header="Office", sortable=True),
+ ],
+ rows=EMPLOYEES,
+ searchable=True,
+ paginated=True,
+ page_size=15,
+ )
+
+ return PrefabApp(view=view)
+
+
+# ---------------------------------------------------------------------------
+# Forms
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def contact_form() -> PrefabApp:
+ """Show a form to create a new contact, with a live contact list below."""
+ with Column(gap=6, css_class="p-6") as view:
+ Heading("Contacts")
+
+ with ForEach("contacts"):
+ with Row(gap=2, align="center"):
+ Text("{{ name }}", css_class="font-medium")
+ Muted("{{ email }}")
+ Badge("{{ category }}")
+
+ Separator()
+
+ Heading("Add Contact", level=3)
+ with Form(
+ on_submit=CallTool(
+ "save_contact",
+ result_key="contacts",
+ on_success=ShowToast("Contact saved!", variant="success"),
+ on_error=ShowToast("{{ $error }}", variant="error"),
+ )
+ ):
+ Input(name="name", label="Full Name", required=True)
+ Input(name="email", label="Email", input_type="email", required=True)
+ Select(
+ name="category",
+ label="Category",
+ options=["Customer", "Vendor", "Partner", "Other"],
+ )
+ Textarea(name="notes", label="Notes", placeholder="Optional notes...")
+ Button("Save Contact")
+
+ return PrefabApp(view=view, state={"contacts": list(_contacts)})
+
+
+@mcp.tool
+def save_contact(
+ name: str,
+ email: str,
+ category: str = "Other",
+ notes: str = "",
+) -> list[dict]:
+ """Save a new contact and return the updated list."""
+ contact = {"name": name, "email": email, "category": category, "notes": notes}
+ _contacts.append(contact)
+ return list(_contacts)
+
+
+# ---------------------------------------------------------------------------
+# Status Displays
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def system_status() -> PrefabApp:
+ """Show current system health."""
+ all_ok = all(s["ok"] for s in SERVICES)
+
+ with Column(gap=4, css_class="p-6") as view:
+ with Row(gap=2, align="center"):
+ Heading("System Status")
+ Badge(
+ "All Healthy" if all_ok else "Degraded",
+ variant="success" if all_ok else "destructive",
+ )
+
+ Separator()
+
+ with Grid(columns=2, gap=4):
+ for svc in SERVICES:
+ with Card():
+ with CardContent():
+ with Row(gap=2, align="center"):
+ Text(svc["name"], css_class="font-medium")
+ Badge(
+ svc["status"],
+ variant="success" if svc["ok"] else "destructive",
+ )
+ Muted(f"Response: {svc['latency_ms']}ms")
+ Progress(value=svc["uptime_pct"])
+
+ return PrefabApp(view=view)
+
+
+# ---------------------------------------------------------------------------
+# Conditional Content
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def feature_flags() -> PrefabApp:
+ """Toggle feature flags with live preview."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("Feature Flags")
+
+ Switch(name="dark_mode", label="Dark Mode")
+ Switch(name="beta_features", label="Beta Features")
+
+ Separator()
+
+ with If("{{ dark_mode }}"):
+ Alert(title="Dark mode enabled", description="UI will use dark theme.")
+ with If("{{ beta_features }}"):
+ Alert(
+ title="Beta features active",
+ description="Experimental features are now visible.",
+ variant="warning",
+ )
+
+ return PrefabApp(view=view, state={"dark_mode": False, "beta_features": False})
+
+
+# ---------------------------------------------------------------------------
+# Tabs
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def project_overview() -> PrefabApp:
+ """Show project details organized in tabs."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(PROJECT["name"])
+
+ with Tabs():
+ with Tab("Overview"):
+ Text(PROJECT["description"])
+ with Row(gap=4):
+ Badge(PROJECT["status"])
+ Muted(f"Created: {PROJECT['created_at']}")
+
+ with Tab("Members"):
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="role", header="Role"),
+ ],
+ rows=PROJECT["members"],
+ )
+
+ with Tab("Activity"):
+ with ForEach("activity"):
+ with Row(gap=2):
+ Muted("{{ timestamp }}")
+ Text("{{ message }}")
+
+ return PrefabApp(view=view, state={"activity": PROJECT["activity"]})
+
+
+# ---------------------------------------------------------------------------
+# Accordion
+# ---------------------------------------------------------------------------
+
+
+@mcp.tool(app=True)
+def api_health() -> PrefabApp:
+ """Show health details for each API endpoint."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading("API Health")
+
+ with Accordion(multiple=True):
+ for ep in ENDPOINTS:
+ with AccordionItem(ep["path"]):
+ with Row(gap=4):
+ Badge(
+ f"{ep['status']}",
+ variant="success" if ep["healthy"] else "destructive",
+ )
+ Text(f"Avg: {ep['avg_ms']}ms")
+ Text(f"P99: {ep['p99_ms']}ms")
+ Progress(value=ep["uptime_pct"])
+
+ return PrefabApp(view=view)
+
+
+if __name__ == "__main__":
+ mcp.run()
diff --git a/pyproject.toml b/pyproject.toml
index f0299ba15..81d7cadb0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,6 +52,7 @@ classifiers = [
[project.optional-dependencies]
anthropic = ["anthropic>=0.40.0"]
+apps = ["prefab-ui>=0.6.0"]
azure = ["azure-identity>=1.16.0"]
code-mode = ["pydantic-monty>=0.0.7"]
openai = ["openai>=1.102.0"]
@@ -60,7 +61,7 @@ tasks = ["pydocket>=0.17.2"]
[dependency-groups]
dev = [
"dirty-equals>=0.9.0",
- "fastmcp[anthropic,azure,code-mode,openai,tasks]",
+ "fastmcp[anthropic,apps,azure,code-mode,openai,tasks]",
# add optional dependencies for fastmcp dev
"fastapi>=0.115.12",
"opentelemetry-sdk>=1.20.0",
@@ -105,6 +106,7 @@ source = "uv-dynamic-versioning"
[tool.hatch.metadata]
allow-direct-references = true
+
[tool.uv-dynamic-versioning]
vcs = "git"
style = "pep440"
@@ -191,18 +193,6 @@ known-first-party = ["fastmcp"]
"SIM", # flake8-simplify
]
-[tool.basedpyright]
-pythonVersion = "3.10"
-typeCheckingMode = "standard"
-reportMissingTypeStubs = false
-reportUnknownParameterType = false
-reportUnknownArgumentType = false
-reportUnknownMemberType = false
-reportUnknownVariableType = false
-reportPrivateUsage = false
-reportUnnecessaryIsInstance = false
-reportUnnecessaryComparison = false
-reportConstantRedefinition = false
[tool.codespell]
ignore-words-list = "asend,shttp,te"
diff --git a/src/fastmcp/resources/types.py b/src/fastmcp/resources/types.py
index 5f7fbd511..30642683b 100644
--- a/src/fastmcp/resources/types.py
+++ b/src/fastmcp/resources/types.py
@@ -26,7 +26,11 @@ class TextResource(Resource):
async def read(self) -> ResourceResult:
"""Read the text content."""
return ResourceResult(
- contents=[ResourceContent(content=self.text, mime_type=self.mime_type)]
+ contents=[
+ ResourceContent(
+ content=self.text, mime_type=self.mime_type, meta=self.meta
+ )
+ ]
)
@@ -38,7 +42,11 @@ class BinaryResource(Resource):
async def read(self) -> ResourceResult:
"""Read the binary content."""
return ResourceResult(
- contents=[ResourceContent(content=self.data, mime_type=self.mime_type)]
+ contents=[
+ ResourceContent(
+ content=self.data, mime_type=self.mime_type, meta=self.meta
+ )
+ ]
)
diff --git a/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py
index 9da7bc8e2..fcb0b673c 100644
--- a/src/fastmcp/server/apps.py
+++ b/src/fastmcp/server/apps.py
@@ -7,7 +7,7 @@ UI metadata for clients that support interactive app rendering.
from __future__ import annotations
-from typing import Any
+from typing import Any, Literal
from pydantic import BaseModel, Field
@@ -92,7 +92,7 @@ class AppConfig(BaseModel):
alias="resourceUri",
description="URI of the UI resource (typically ui:// scheme). Tools only.",
)
- visibility: list[str] | None = Field(
+ visibility: list[Literal["app", "model"]] | None = Field(
default=None,
description="Where this tool is visible: 'app', 'model', or both. Tools only.",
)
diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py
index 93209eb21..796be4224 100644
--- a/src/fastmcp/server/providers/local_provider/decorators/tools.py
+++ b/src/fastmcp/server/providers/local_provider/decorators/tools.py
@@ -7,10 +7,21 @@ registration functionality to LocalProvider.
from __future__ import annotations
import inspect
+import types
import warnings
from collections.abc import Callable
from functools import partial
-from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
+from typing import (
+ TYPE_CHECKING,
+ Annotated,
+ Any,
+ Literal,
+ TypeVar,
+ Union,
+ get_args,
+ get_origin,
+ overload,
+)
import mcp.types
from mcp.types import AnyFunction, ToolAnnotations
@@ -22,6 +33,14 @@ from fastmcp.tools.function_tool import FunctionTool
from fastmcp.tools.tool import Tool
from fastmcp.utilities.types import NotSet, NotSetT
+try:
+ from prefab_ui.app import PrefabApp as _PrefabApp
+ from prefab_ui.components.base import Component as _PrefabComponent
+
+ _HAS_PREFAB = True
+except ImportError:
+ _HAS_PREFAB = False
+
if TYPE_CHECKING:
from fastmcp.server.providers.local_provider import LocalProvider
from fastmcp.tools.tool import ToolResultSerializerType
@@ -30,6 +49,99 @@ F = TypeVar("F", bound=Callable[..., Any])
DuplicateBehavior = Literal["error", "warn", "replace", "ignore"]
+PREFAB_RENDERER_URI = "ui://prefab/renderer.html"
+
+
+def _is_prefab_type(tp: Any) -> bool:
+ """Check if *tp* is or contains a prefab type, recursing through unions and Annotated."""
+ if isinstance(tp, type) and issubclass(tp, (_PrefabApp, _PrefabComponent)):
+ return True
+ origin = get_origin(tp)
+ if origin is Union or origin is types.UnionType or origin is Annotated:
+ return any(_is_prefab_type(a) for a in get_args(tp))
+ return False
+
+
+def _has_prefab_return_type(tool: Tool) -> bool:
+ """Check if a FunctionTool's return type annotation is a prefab type."""
+ if not _HAS_PREFAB or not isinstance(tool, FunctionTool):
+ return False
+ rt = tool.return_type
+ if rt is None or rt is inspect.Parameter.empty:
+ return False
+ return _is_prefab_type(rt)
+
+
+def _ensure_prefab_renderer(provider: LocalProvider) -> None:
+ """Lazily register the shared prefab renderer as a ui:// resource."""
+ from prefab_ui.renderer import get_renderer_csp, get_renderer_html
+
+ from fastmcp.resources.types import TextResource
+ from fastmcp.server.apps import (
+ UI_MIME_TYPE,
+ AppConfig,
+ ResourceCSP,
+ app_config_to_meta_dict,
+ )
+
+ renderer_key = f"resource:{PREFAB_RENDERER_URI}@"
+ if renderer_key in provider._components:
+ return
+
+ csp = get_renderer_csp()
+ resource_app = AppConfig(
+ csp=ResourceCSP(
+ resource_domains=csp.get("resource_domains"),
+ connect_domains=csp.get("connect_domains"),
+ )
+ )
+ resource = TextResource(
+ uri=PREFAB_RENDERER_URI, # type: ignore[arg-type] # AnyUrl accepts ui:// scheme at runtime
+ name="Prefab Renderer",
+ text=get_renderer_html(),
+ mime_type=UI_MIME_TYPE,
+ meta={"ui": app_config_to_meta_dict(resource_app)},
+ )
+ provider._add_component(resource)
+
+
+def _expand_prefab_ui_meta(tool: Tool) -> None:
+ """Expand meta["ui"] = True into the full AppConfig dict for a prefab tool."""
+ from prefab_ui.renderer import get_renderer_csp
+
+ from fastmcp.server.apps import AppConfig, ResourceCSP, app_config_to_meta_dict
+
+ csp = get_renderer_csp()
+ app_config = AppConfig(
+ resource_uri=PREFAB_RENDERER_URI,
+ csp=ResourceCSP(
+ resource_domains=csp.get("resource_domains"),
+ connect_domains=csp.get("connect_domains"),
+ ),
+ )
+ meta = dict(tool.meta) if tool.meta else {}
+ meta["ui"] = app_config_to_meta_dict(app_config)
+ tool.meta = meta
+
+
+def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None:
+ """Auto-wire prefab UI metadata and renderer resource if needed."""
+ if not _HAS_PREFAB:
+ return
+
+ meta = tool.meta or {}
+ ui = meta.get("ui")
+
+ if ui is True:
+ # Explicit app=True: expand to full AppConfig and register renderer
+ _ensure_prefab_renderer(provider)
+ _expand_prefab_ui_meta(tool)
+ elif ui is None and _has_prefab_return_type(tool):
+ # 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
+
class ToolDecoratorMixin:
"""Mixin class providing tool decorator functionality for LocalProvider.
@@ -87,6 +199,7 @@ class ToolDecoratorMixin:
self._add_component(tool)
if not enabled:
self.disable(keys={tool.key})
+ _maybe_apply_prefab_ui(self, tool)
return tool
@overload
@@ -264,6 +377,7 @@ class ToolDecoratorMixin:
self._add_component(tool_obj)
if not enabled:
self.disable(keys={tool_obj.key})
+ _maybe_apply_prefab_ui(self, tool_obj)
return tool_obj
else:
from fastmcp.tools.function_tool import ToolMeta
diff --git a/src/fastmcp/tools/function_parsing.py b/src/fastmcp/tools/function_parsing.py
index d48f6dbe6..a056c37c9 100644
--- a/src/fastmcp/tools/function_parsing.py
+++ b/src/fastmcp/tools/function_parsing.py
@@ -3,9 +3,10 @@
from __future__ import annotations
import inspect
+import types
from collections.abc import Callable
from dataclasses import dataclass
-from typing import Any, Generic, get_type_hints
+from typing import Annotated, Any, Generic, Union, get_args, get_origin, get_type_hints
import mcp.types
from pydantic import PydanticSchemaGenerationError
@@ -27,6 +28,25 @@ from fastmcp.utilities.types import (
replace_type,
)
+try:
+ from prefab_ui.app import PrefabApp as _PrefabApp
+ from prefab_ui.components.base import Component as _PrefabComponent
+
+ _PREFAB_TYPES: tuple[type, ...] = (_PrefabApp, _PrefabComponent)
+except ImportError:
+ _PREFAB_TYPES = ()
+
+
+def _contains_prefab_type(tp: Any) -> bool:
+ """Check if *tp* is or contains a prefab type, recursing through unions and Annotated."""
+ if isinstance(tp, type) and issubclass(tp, _PREFAB_TYPES):
+ return True
+ origin = get_origin(tp)
+ if origin is Union or origin is types.UnionType or origin is Annotated:
+ return any(_contains_prefab_type(a) for a in get_args(tp))
+ return False
+
+
T = TypeVarExt("T", default=Any)
logger = get_logger(__name__)
@@ -65,6 +85,7 @@ class ParsedFunction:
description: str | None
input_schema: dict[str, Any]
output_schema: dict[str, Any] | None
+ return_type: Any = None
@classmethod
def from_function(
@@ -145,7 +166,18 @@ class ParsedFunction:
# If resolution fails, keep the string annotation
logger.debug("Failed to resolve type hint for return annotation: %s", e)
+ # Save original for return_type before any schema-related replacement
+ original_output_type = output_type
+
if output_type not in (inspect._empty, None, Any, ...):
+ # Prefab component subclasses (Column, Card, etc.) shouldn't
+ # produce output schemas — replace_type only does exact matching,
+ # so we handle subclass matching explicitly here. We also need
+ # to handle composite types like ``Column | None`` and
+ # ``Annotated[PrefabApp, ...]`` by recursing into their args.
+ if _PREFAB_TYPES and _contains_prefab_type(output_type):
+ output_type = _UnserializableType
+
# there are a variety of types that we don't want to attempt to
# serialize because they are either used by FastMCP internally,
# or are MCP content types that explicitly don't form structured
@@ -164,6 +196,7 @@ class ParsedFunction:
mcp.types.AudioContent,
mcp.types.ResourceLink,
mcp.types.EmbeddedResource,
+ *_PREFAB_TYPES,
),
_UnserializableType,
),
@@ -198,4 +231,5 @@ class ParsedFunction:
description=fn_doc,
input_schema=input_schema,
output_schema=output_schema or None,
+ return_type=original_output_type,
)
diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py
index 6c1a361f6..e88828a80 100644
--- a/src/fastmcp/tools/function_tool.py
+++ b/src/fastmcp/tools/function_tool.py
@@ -8,6 +8,7 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from typing import (
TYPE_CHECKING,
+ Annotated,
Any,
Literal,
Protocol,
@@ -20,6 +21,7 @@ import anyio
import mcp.types
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, Icon, ToolAnnotations, ToolExecution
+from pydantic import Field
from pydantic.json_schema import SkipJsonSchema
import fastmcp
@@ -84,6 +86,7 @@ class ToolMeta:
class FunctionTool(Tool):
fn: SkipJsonSchema[Callable[..., Any]]
+ return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None
def to_mcp_tool(
self,
@@ -230,6 +233,7 @@ class FunctionTool(Tool):
return cls(
fn=parsed_fn.fn,
+ return_type=parsed_fn.return_type,
name=metadata.name or parsed_fn.name,
version=str(metadata.version) if metadata.version is not None else None,
title=metadata.title,
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index 3c99db465..b23ebfc8b 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -38,6 +38,14 @@ from fastmcp.utilities.types import (
NotSetT,
)
+try:
+ from prefab_ui.app import PrefabApp as _PrefabApp
+ from prefab_ui.components.base import Component as _PrefabComponent
+
+ _HAS_PREFAB = True
+except ImportError:
+ _HAS_PREFAB = False
+
if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
@@ -82,6 +90,14 @@ class ToolResult(BaseModel):
converted_content: list[ContentBlock] = _convert_to_content(result=content)
if structured_content is not None:
+ # Convert Prefab types to their wire-format envelope before
+ # generic serialization, so the renderer gets the right shape.
+ if _HAS_PREFAB:
+ if isinstance(structured_content, _PrefabApp):
+ structured_content = structured_content.to_json()
+ elif isinstance(structured_content, _PrefabComponent):
+ structured_content = _PrefabApp(view=structured_content).to_json()
+
try:
structured_content = pydantic_core.to_jsonable_python(
value=structured_content
@@ -248,6 +264,12 @@ class Tool(FastMCPComponent):
if isinstance(raw_value, ToolResult):
return raw_value
+ if _HAS_PREFAB:
+ if isinstance(raw_value, _PrefabApp):
+ return _prefab_to_tool_result(raw_value)
+ if isinstance(raw_value, _PrefabComponent):
+ return _prefab_to_tool_result(_PrefabApp(view=raw_value))
+
content = _convert_to_content(raw_value, serializer=self.serializer)
# Skip structured content for ContentBlock types only if no output_schema
@@ -454,6 +476,17 @@ def _convert_to_single_content_block(
return TextContent(type="text", text=_serialize_with_fallback(item, serializer))
+_PREFAB_TEXT_FALLBACK = "[Rendered Prefab UI]"
+
+
+def _prefab_to_tool_result(app: Any) -> ToolResult:
+ """Convert a PrefabApp to a FastMCP ToolResult."""
+ return ToolResult(
+ content=[TextContent(type="text", text=_PREFAB_TEXT_FALLBACK)],
+ structured_content=app.to_json(),
+ )
+
+
def _convert_to_content(
result: Any,
serializer: ToolResultSerializerType | None = None,
diff --git a/tests/test_apps_prefab.py b/tests/test_apps_prefab.py
new file mode 100644
index 000000000..301141d05
--- /dev/null
+++ b/tests/test_apps_prefab.py
@@ -0,0 +1,431 @@
+"""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 "