mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Add Prefab Apps integration for MCP tool UIs (#3316)
* 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.
This commit is contained in:
parent
2d1fe3ec0f
commit
3ff1472ea9
18 changed files with 2143 additions and 38 deletions
|
|
@ -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'
|
|||
|
||||
<VersionBadge version="3.0.0" />
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -10,22 +10,63 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
|
|||
|
||||
<VersionBadge version="3.0.0" />
|
||||
|
||||
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:
|
||||
<VersionBadge version="3.1.0" />
|
||||
|
||||
- **`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 "<html>...</html>"
|
||||
```
|
||||
|
||||
See [Custom HTML Apps](/apps/low-level) for the full reference.
|
||||
|
|
|
|||
479
docs/apps/patterns.mdx
Normal file
479
docs/apps/patterns.mdx
Normal file
|
|
@ -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'
|
||||
|
||||
<VersionBadge version="3.1.0" />
|
||||
|
||||
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
|
||||
191
docs/apps/prefab.mdx
Normal file
191
docs/apps/prefab.mdx
Normal file
|
|
@ -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'
|
||||
|
||||
<VersionBadge version="3.1.0" />
|
||||
|
||||
[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]"
|
||||
```
|
||||
|
||||
<Tip>
|
||||
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
|
||||
]
|
||||
```
|
||||
</Tip>
|
||||
|
||||
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
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
html:not([data-page-mode="wide"]) #content-area {
|
||||
max-width: 44rem !important;
|
||||
}
|
||||
|
||||
img.nav-logo {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,6 +202,8 @@
|
|||
"group": "Apps",
|
||||
"pages": [
|
||||
"apps/overview",
|
||||
"apps/prefab",
|
||||
"apps/patterns",
|
||||
"apps/low-level"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
102
examples/apps/chart_server.py
Normal file
102
examples/apps/chart_server.py
Normal file
|
|
@ -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()
|
||||
165
examples/apps/datatable_server.py
Normal file
165
examples/apps/datatable_server.py
Normal file
|
|
@ -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()
|
||||
489
examples/apps/patterns_server.py
Normal file
489
examples/apps/patterns_server.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
431
tests/test_apps_prefab.py
Normal file
431
tests/test_apps_prefab.py
Normal file
|
|
@ -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 "<html" in text.lower() or "<!doctype" in text.lower()
|
||||
24
uv.lock
generated
24
uv.lock
generated
|
|
@ -786,6 +786,9 @@ dependencies = [
|
|||
anthropic = [
|
||||
{ name = "anthropic" },
|
||||
]
|
||||
apps = [
|
||||
{ name = "prefab-ui" },
|
||||
]
|
||||
azure = [
|
||||
{ name = "azure-identity" },
|
||||
]
|
||||
|
|
@ -803,7 +806,7 @@ tasks = [
|
|||
dev = [
|
||||
{ name = "dirty-equals" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastmcp", extra = ["anthropic", "azure", "code-mode", "openai", "tasks"] },
|
||||
{ name = "fastmcp", extra = ["anthropic", "apps", "azure", "code-mode", "openai", "tasks"] },
|
||||
{ name = "inline-snapshot", extra = ["dirty-equals"] },
|
||||
{ name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
|
|
@ -845,6 +848,7 @@ requires-dist = [
|
|||
{ name = "opentelemetry-api", specifier = ">=1.20.0" },
|
||||
{ name = "packaging", specifier = ">=24.0" },
|
||||
{ name = "platformdirs", specifier = ">=4.0.0" },
|
||||
{ name = "prefab-ui", marker = "extra == 'apps'", specifier = ">=0.6.0" },
|
||||
{ name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], specifier = ">=0.4.4,<0.5.0" },
|
||||
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
|
||||
{ name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = ">=0.0.7" },
|
||||
|
|
@ -857,13 +861,13 @@ requires-dist = [
|
|||
{ name = "watchfiles", specifier = ">=1.0.0" },
|
||||
{ name = "websockets", specifier = ">=15.0.1" },
|
||||
]
|
||||
provides-extras = ["anthropic", "azure", "code-mode", "openai", "tasks"]
|
||||
provides-extras = ["anthropic", "apps", "azure", "code-mode", "openai", "tasks"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "dirty-equals", specifier = ">=0.9.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.12" },
|
||||
{ name = "fastmcp", extras = ["anthropic", "azure", "code-mode", "openai", "tasks"] },
|
||||
{ name = "fastmcp", extras = ["anthropic", "apps", "azure", "code-mode", "openai", "tasks"] },
|
||||
{ name = "inline-snapshot", extras = ["dirty-equals"], specifier = ">=0.27.2" },
|
||||
{ name = "ipython", specifier = ">=8.12.3" },
|
||||
{ name = "loq", specifier = ">=0.1.0a3" },
|
||||
|
|
@ -1709,6 +1713,20 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prefab-ui"
|
||||
version = "0.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cyclopts" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/32/e7bc3db47cd93e2683241bd2a328b3136c44c4332a4277a2ac2f3f6beca4/prefab_ui-0.8.3.tar.gz", hash = "sha256:bfe1304f0fc457da764763232f533bff732160e8cc8ce12977ba48f8e7574152", size = 2819196, upload-time = "2026-02-27T14:44:10.52Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/cbc8decc176428a1f03b676fee4242f8a8d0ae7e76d5fc76ff96f13f13c1/prefab_ui-0.8.3-py3-none-any.whl", hash = "sha256:b1bac064958f08b20694e67b916ad8ab82de8db883578c31ba4437b521b425e1", size = 803888, upload-time = "2026-02-27T14:44:09.31Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prek"
|
||||
version = "0.3.3"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue