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 "