diff --git a/docs/apps/architecture.mdx b/docs/apps/architecture.mdx
new file mode 100644
index 000000000..8fe5c3868
--- /dev/null
+++ b/docs/apps/architecture.mdx
@@ -0,0 +1,118 @@
+---
+title: Architecture
+sidebarTitle: Architecture
+description: How Prefab apps work under the hood — from Python to pixels.
+icon: sitemap
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+This page explains the internal architecture of Prefab apps — how your Python code becomes an interactive UI inside a host client's conversation. If you're building [custom HTML apps](/apps/low-level), the pipeline is simpler and covered on that page. You don't need to understand any of this to build Prefab apps, but the mental model is useful when you're debugging, extending, or contributing.
+
+## The Pipeline
+
+An MCP App moves through five stages from Python to pixels:
+
+```
+Python components → JSON tree → structuredContent → Renderer iframe → Host UI
+```
+
+You write Prefab components in Python. FastMCP serializes them to a JSON component tree and delivers it as `structuredContent` on the tool result. The host loads the Prefab renderer in a sandboxed iframe, pushes the JSON into it, and the renderer paints the UI. If the UI needs to call server tools, it talks back through the same `postMessage` channel.
+
+The following sections walk through each stage.
+
+## Tool Registration
+
+When you mark a tool with `app=True` or `@app.ui()`, FastMCP wires up the metadata and renderer resource that the protocol requires.
+
+### The `app=True` Flag
+
+The `app` parameter on `@mcp.tool` accepts `True`, an `AppConfig` object, or a dict. When you pass `True`, FastMCP checks whether the tool's return type is a Prefab type (`PrefabApp`, `Component`, or unions containing them). If the tool qualifies, FastMCP expands `True` into a full `AppConfig` — setting the renderer URI, CSP headers, and visibility — and stores it in the tool's `meta["ui"]` dict.
+
+This expansion also triggers registration of the shared Prefab renderer resource (discussed below). The tool and the renderer are linked through a `resourceUri` field in the metadata: the tool says "render me with `ui://prefab/renderer.html`", and the host fetches that resource when it needs to display the result.
+
+Type inference works the same way. If your return type annotation is a Prefab type and you haven't set `app` explicitly, FastMCP auto-wires the metadata as if you'd written `app=True`.
+
+### FastMCPApp Registration
+
+`FastMCPApp` uses the same underlying mechanism but adds two things. First, it tags every tool — both `@app.ui()` entry points and `@app.tool()` backends — with `meta["fastmcp"]["app"]` set to the app's name. This tag is how the server identifies which app a tool belongs to when routing calls from the UI.
+
+Second, it sets `meta["ui"]["visibility"]` to control who can see each tool. Entry points default to `["model"]` (visible to the LLM). Backend tools default to `["app"]` (visible only to the UI). Hosts use this to filter the tool list — the model sees entry points, and the UI sees backends.
+
+## Serialization
+
+When a Prefab tool runs, its return value — a `PrefabApp` or a raw `Component` — needs to become a JSON blob that the renderer can interpret.
+
+### PrefabApp.to_json()
+
+The serialization entry point is `PrefabApp.to_json()`. This method walks the component tree and produces a JSON object with three top-level keys: `view` (the component tree), `state` (initial state values), and `_meta` (routing metadata).
+
+FastMCP passes a `tool_resolver` callback to `to_json()`. Whenever the component tree contains a `CallTool` action that references a function (not a string), the resolver converts it to a `ResolvedTool` with the function's registered name. This is how `CallTool(save_contact)` becomes `CallTool("save_contact")` in the wire format. The resolver also handles `unwrap_result` — a flag that tells the renderer to unwrap single-value results from the `{"result": value}` envelope that FastMCP uses for schema compliance.
+
+### The _meta.fastmcp.app Tag
+
+After `to_json()` produces the JSON tree, FastMCP injects `_meta.fastmcp.app` with the app's name (if the tool belongs to a `FastMCPApp`). This tag rides along inside `structuredContent` all the way to the renderer.
+
+When the renderer calls a backend tool, it includes `_meta.fastmcp.app` in the `CallTool` request. The server sees this tag and routes the call through a special path that bypasses transforms — more on this in the next section.
+
+### ToolResult Assembly
+
+The final tool result has two parts: `content` (a list of `TextContent` blocks for the LLM) and `structuredContent` (the JSON tree for the renderer). By default, Prefab tools send `"[Rendered Prefab UI]"` as the text content — just enough for the LLM to know something was rendered. If you return a `ToolResult` explicitly, you control both halves.
+
+## Tool Call Routing
+
+When a host calls a tool, the server needs to find it. Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters, etc.) before resolving the tool by name. But app UI calls need a different path.
+
+### The get_app_tool Bypass
+
+Backend tools registered with `@app.tool()` are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — but the renderer still uses the original name.
+
+`get_app_tool` solves both problems. When the server sees `_meta.fastmcp.app` on an incoming `CallTool` request, it calls `get_app_tool(app_name, tool_name)` instead of the normal `get_tool(name)`. This method walks the provider tree directly, skipping the transform chain entirely. It finds the tool by its original registered name and verifies that its `meta["fastmcp"]["app"]` matches the expected app identity.
+
+This is why `CallTool("save_contact")` keeps working even when the server is mounted under a namespace prefix. The renderer sends the original name plus the app identity; the server uses `get_app_tool` to find the tool without transforms getting in the way.
+
+Authorization checks still apply — `get_app_tool` bypasses transforms, but it runs auth checks against the tool's `auth` configuration before executing.
+
+### Provider Delegation
+
+The `get_app_tool` method is defined on the `Provider` base class and overridden by aggregate and wrapped providers. Aggregate providers fan out the lookup across all child providers in parallel. Wrapped providers (like `FastMCPProvider`, which wraps a nested `FastMCP` server) delegate to the inner server's `get_app_tool`. This means backend tools are reachable through any depth of server composition.
+
+## The Renderer
+
+The Prefab renderer is a self-contained JavaScript application that interprets the JSON component tree and renders it as a React UI.
+
+### The Shared Resource
+
+FastMCP registers the renderer as a `ui://prefab/renderer.html` resource with MIME type `text/html;profile=mcp-app`. The renderer HTML is bundled inside the `prefab-ui` Python package — `get_renderer_html()` returns it as a string. All Prefab tools on a server share this single resource, regardless of how many tools or apps are registered.
+
+The resource also carries CSP metadata (via `get_renderer_csp()`) declaring which CDN domains the renderer needs to load its JavaScript dependencies. Hosts use this to configure the iframe's Content Security Policy.
+
+### postMessage Communication
+
+The renderer lives in a sandboxed iframe. It communicates with the host using `postMessage` — the standard browser API for cross-origin iframe communication. The protocol follows the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) specification:
+
+The host pushes the tool result (including `structuredContent`) into the iframe. The renderer parses the JSON component tree, initializes state, and renders the UI. When the user interacts with the UI — submitting a form, clicking a button — and that interaction triggers a `CallTool` action, the renderer sends a `callServerTool` message back to the host via `postMessage`. The host forwards this as a regular MCP `tools/call` request to the server, including `_meta.fastmcp.app` for routing.
+
+The response flows back the same way: server to host, host to iframe via `postMessage`, renderer updates state with the result.
+
+### AppBridge
+
+The `@modelcontextprotocol/ext-apps` JavaScript SDK provides the `App` class (sometimes called AppBridge) that manages the `postMessage` handshake. It handles connection negotiation, tool result delivery, server tool calls, and host context (like safe area insets and theme preferences). The Prefab renderer uses this SDK internally — you only interact with it directly when building [custom HTML apps](/apps/low-level).
+
+## The Dev Server
+
+`fastmcp dev apps` provides a local preview environment that simulates the host-side behavior without requiring a real MCP host client.
+
+### Proxy Architecture
+
+The dev server runs two HTTP servers. Your MCP server starts on port 8000 (configurable) with the Streamable HTTP transport. The dev UI runs on port 8080 and serves a picker page that lists your app tools.
+
+A reverse proxy at `/mcp` on the dev server forwards requests to your MCP server. This is important because the renderer iframe runs on `localhost:8080`, and your MCP server runs on `localhost:8000`. Without the proxy, the renderer's `callServerTool` requests would be cross-origin and blocked by the browser. The proxy makes everything same-origin from the iframe's perspective.
+
+### The Launch Flow
+
+When you select a tool and click launch, the dev UI calls the tool through the proxy, receives the `structuredContent` response, and opens a new tab. That tab loads the tool's renderer resource (fetched from the proxy) in an iframe, creates an AppBridge instance, and pushes the tool result into the renderer. From this point forward, the experience matches what a real host would provide — the renderer displays the UI, and any `CallTool` actions route back through the proxy to your MCP server.
+
+Auto-reload is enabled by default, so changes to your server code restart the MCP server automatically. The dev UI stays running — just re-launch the tool to see your changes.
diff --git a/docs/apps/components.mdx b/docs/apps/components.mdx
new file mode 100644
index 000000000..22f4b9094
--- /dev/null
+++ b/docs/apps/components.mdx
@@ -0,0 +1,599 @@
+---
+title: Component Reference
+sidebarTitle: Components
+description: Quick reference for the most-used Prefab components.
+icon: shapes
+tag: NEW
+---
+
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
+This page is a scannable reference for the Prefab components you'll use most often in MCP Apps. Each entry shows the component, what it does, a minimal code example, and the props that matter.
+
+For the full component library — every prop, variant, and advanced pattern — see the [Prefab component reference](https://prefab.prefect.io/docs/components).
+
+
+All components below are imported from `prefab_ui.components` unless otherwise noted. Charts must be imported from `prefab_ui.components.charts`.
+
+
+## Layout
+
+Layout components control how children are arranged. They all use Python's `with` statement to collect their children.
+
+### Column
+
+Stacks children vertically. The most common top-level container for an app view.
+
+```python
+from prefab_ui.components import Column, Text
+
+with Column(gap=4, css_class="p-6") as view:
+ Text("First")
+ Text("Second")
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `gap` | `int` | Space between children (Tailwind units) |
+| `align` | `str` | Cross-axis alignment: `"start"`, `"center"`, `"end"`, `"stretch"` |
+| `justify` | `str` | Main-axis alignment: `"start"`, `"center"`, `"end"`, `"between"` |
+| `css_class` | `str` | Tailwind CSS classes |
+
+### Row
+
+Arranges children horizontally.
+
+```python
+from prefab_ui.components import Row, Badge, Text
+
+with Row(gap=2, align="center"):
+ Text("Status")
+ Badge("Online", variant="success")
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `gap` | `int` | Space between children |
+| `align` | `str` | Cross-axis alignment |
+| `justify` | `str` | Main-axis alignment |
+| `wrap` | `bool` | Wrap children to next line |
+
+### Grid
+
+Lays out children in a CSS grid with a fixed number of columns.
+
+```python
+from prefab_ui.components import Grid, Card, CardContent, Text
+
+with Grid(columns=3, gap=4):
+ for label in ["API", "Cache", "DB"]:
+ with Card():
+ with CardContent():
+ Text(label)
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `columns` | `int` | Number of grid columns |
+| `gap` | `int` | Space between cells |
+
+### Card / CardContent
+
+A bordered container with padding. `Card` provides the outer border and shadow; `CardContent` adds standard inner padding. Cards are commonly used inside grids for dashboard-style layouts.
+
+```python
+from prefab_ui.components import Card, CardContent, Text, Badge
+
+with Card():
+ with CardContent():
+ Text("API Gateway")
+ Badge("healthy", variant="success")
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `css_class` | `str` | Additional Tailwind classes |
+
+### Separator
+
+Renders a horizontal rule between sections. Takes no required props.
+
+```python
+from prefab_ui.components import Column, Heading, Separator, Text
+
+with Column(gap=4):
+ Heading("Section A")
+ Separator()
+ Text("Content below the line")
+```
+
+## Typography
+
+### Heading
+
+Renders a heading element. Defaults to `level=2` (an `
`).
+
+```python
+from prefab_ui.components import Heading
+
+Heading("Dashboard")
+Heading("Subsection", level=3)
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `level` | `int` | Heading level: `1`-`4` |
+
+### Text
+
+General-purpose text element. Accepts reactive expressions (`Rx`) as content so the text can update with state changes.
+
+```python
+from prefab_ui.components import Text
+
+Text("Hello, world")
+Text("Styled text", css_class="font-medium text-blue-500")
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `css_class` | `str` | Tailwind CSS classes |
+
+### Muted
+
+Renders text in a subdued color. Useful for secondary information like timestamps, metadata, or helper text.
+
+```python
+from prefab_ui.components import Muted
+
+Muted("Last updated 5 minutes ago")
+```
+
+### Badge
+
+A small label for status indicators, tags, or categories. Supports color variants to convey meaning at a glance.
+
+```python
+from prefab_ui.components import Badge
+
+Badge("Active", variant="success")
+Badge("Pending", variant="warning")
+Badge("Failed", variant="destructive")
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `variant` | `str` | `"default"`, `"success"`, `"warning"`, `"destructive"`, `"outline"` |
+
+## Data
+
+### DataTable
+
+A fully interactive table with client-side sorting, searching, and pagination. You define columns and pass row data as a list of dicts.
+
+```python
+from prefab_ui.components import DataTable, DataTableColumn
+
+DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="role", header="Role"),
+ ],
+ rows=[
+ {"name": "Alice", "role": "Engineer"},
+ {"name": "Bob", "role": "Designer"},
+ ],
+ search=True,
+ paginated=True,
+ page_size=15,
+)
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `columns` | `list[DataTableColumn]` | Column definitions |
+| `rows` | `list[dict]` | Row data |
+| `search` | `bool` | Enable full-text search |
+| `paginated` | `bool` | Enable pagination |
+| `page_size` | `int` | Rows per page (default `10`) |
+
+`DataTableColumn` takes `key` (the dict key), `header` (display name), and `sortable` (enable sorting on that column).
+
+### BarChart
+
+Renders vertical or horizontal bar charts. Each `ChartSeries` maps a key from your data to a colored bar group. Multiple series produce grouped (or stacked) bars.
+
+```python
+from prefab_ui.components.charts import BarChart, ChartSeries
+
+BarChart(
+ data=[
+ {"month": "Jan", "revenue": 4200},
+ {"month": "Feb", "revenue": 5100},
+ ],
+ series=[ChartSeries(data_key="revenue", label="Revenue")],
+ x_axis="month",
+ show_legend=True,
+)
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `data` | `list[dict]` | Chart data |
+| `series` | `list[ChartSeries]` | Data series to plot |
+| `x_axis` | `str` | Key for the x-axis labels |
+| `stacked` | `bool` | Stack bars instead of grouping |
+| `horizontal` | `bool` | Flip axes for horizontal bars |
+| `show_legend` | `bool` | Display the legend |
+| `height` | `int` | Chart height in pixels |
+
+### PieChart
+
+Displays proportional data as slices. Set `inner_radius` for a donut chart. Unlike bar/line charts, `PieChart` uses `data_key` for the numeric value and `name_key` for the label — it doesn't use `ChartSeries`.
+
+```python
+from prefab_ui.components.charts import PieChart
+
+PieChart(
+ data=[
+ {"category": "Bug", "count": 23},
+ {"category": "Feature", "count": 15},
+ ],
+ data_key="count",
+ name_key="category",
+ inner_radius=60,
+ show_legend=True,
+)
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `data` | `list[dict]` | Chart data |
+| `data_key` | `str` | Key for the numeric value |
+| `name_key` | `str` | Key for the label |
+| `inner_radius` | `int` | Inner radius for donut charts (0 = full pie) |
+| `show_legend` | `bool` | Display the legend |
+
+### LineChart
+
+Plots data points connected by lines. Shares the same API as `BarChart` — use `series`, `x_axis`, and optionally `curve` to control interpolation.
+
+```python
+from prefab_ui.components.charts import LineChart, ChartSeries
+
+LineChart(
+ data=[
+ {"day": "Mon", "requests": 120},
+ {"day": "Tue", "requests": 185},
+ ],
+ series=[ChartSeries(data_key="requests", label="Requests")],
+ x_axis="day",
+ curve="smooth",
+ show_dots=True,
+)
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `data` | `list[dict]` | Chart data |
+| `series` | `list[ChartSeries]` | Data series to plot |
+| `x_axis` | `str` | Key for x-axis labels |
+| `curve` | `str` | `"linear"` or `"smooth"` |
+| `show_dots` | `bool` | Show data point markers |
+| `height` | `int` | Chart height in pixels |
+
+## Forms
+
+Form components collect user input. Each has a `name` prop that determines the key in the submitted data. When used inside a `Form`, their values are gathered automatically on submit.
+
+### Input
+
+A single-line text field. Set `input_type` to `"email"`, `"password"`, `"number"`, etc. for browser-native validation.
+
+```python
+from prefab_ui.components import Input
+
+Input(name="email", label="Email", input_type="email", required=True)
+Input(name="search", placeholder="Search...")
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `name` | `str` | State/form key |
+| `label` | `str` | Label text |
+| `placeholder` | `str` | Placeholder text |
+| `input_type` | `str` | HTML input type |
+| `required` | `bool` | Mark as required |
+| `disabled` | `bool` | Disable input |
+
+### Select
+
+A dropdown for choosing from a list of options. Pass a flat list of strings, or structured `SelectOption` objects for custom labels.
+
+```python
+from prefab_ui.components import Select, SelectOption
+
+with Select(name="priority", label="Priority"):
+ SelectOption("Low", value="low")
+ SelectOption("Medium", value="medium")
+ SelectOption("High", value="high")
+```
+
+Options are defined as `SelectOption` children, not as a prop.
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `name` | `str` | State/form key |
+| `label` | `str` | Label text |
+| `placeholder` | `str` | Placeholder text |
+
+### Textarea
+
+A multi-line text area. Works the same as `Input` but renders as a resizable text box.
+
+```python
+from prefab_ui.components import Textarea
+
+Textarea(name="notes", label="Notes", placeholder="Add details...")
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `name` | `str` | State/form key |
+| `label` | `str` | Label text |
+| `placeholder` | `str` | Placeholder text |
+| `rows` | `int` | Visible height in rows |
+
+### Checkbox
+
+A boolean toggle rendered as a checkbox. Binds to state as `True`/`False`.
+
+```python
+from prefab_ui.components import Checkbox
+
+Checkbox(name="agree", label="I agree to the terms")
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `name` | `str` | State/form key |
+| `label` | `str` | Label text |
+
+### Switch
+
+A toggle switch. Functionally identical to `Checkbox` but rendered as a sliding toggle, better suited for settings and feature flags.
+
+```python
+from prefab_ui.components import Switch
+
+Switch(name="dark_mode", label="Dark Mode")
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `name` | `str` | State/form key |
+| `label` | `str` | Label text |
+
+### Slider
+
+A range input for numeric values. The user drags a handle between `min` and `max`.
+
+```python
+from prefab_ui.components import Slider
+
+Slider(name="volume", label="Volume", min=0, max=100, step=1)
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `name` | `str` | State/form key |
+| `label` | `str` | Label text |
+| `min` | `float` | Minimum value |
+| `max` | `float` | Maximum value |
+| `step` | `float` | Step increment |
+
+### Form
+
+Wraps input components and gathers their values on submit. Attach a `CallTool` action to `on_submit` to send the data to the server. Every named input inside the form becomes a key in the arguments dict.
+
+```python
+from prefab_ui.components import Form, Input, Button
+from prefab_ui.actions.mcp import CallTool
+
+with Form(on_submit=CallTool("save_contact")):
+ Input(name="name", label="Name", required=True)
+ Input(name="email", label="Email", required=True)
+ Button("Save")
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `on_submit` | `Action` | Action to run when the form is submitted |
+
+### Button
+
+A clickable button. Inside a `Form`, a button triggers form submission by default. Outside a form, attach actions to `on_click`.
+
+```python
+from prefab_ui.components import Button
+from prefab_ui.actions import SetState
+
+Button("Reset", on_click=SetState("count", 0))
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `variant` | `str` | `"default"`, `"outline"`, `"ghost"`, `"destructive"` |
+| `on_click` | `Action` | Action to run on click |
+| `disabled` | `bool` | Disable the button |
+
+## Containers
+
+### Tabs / Tab
+
+Organizes content into switchable panels. Each `Tab` becomes a panel with a label in the tab bar. Switching tabs is instant — all panels are rendered, only one is visible.
+
+```python
+from prefab_ui.components import Tabs, Tab, Text
+
+with Tabs():
+ with Tab("Overview"):
+ Text("Overview content here")
+ with Tab("Details"):
+ Text("Detail content here")
+```
+
+| Prop (Tabs) | Type | Description |
+|-------------|------|-------------|
+| `value` | `str` | Label of the initially active tab |
+
+### Accordion / AccordionItem
+
+Collapsible sections. Each `AccordionItem` has a title that toggles its content open and closed. By default, only one item is open at a time.
+
+```python
+from prefab_ui.components import Accordion, AccordionItem, Text
+
+with Accordion(multiple=True):
+ with AccordionItem("Section A"):
+ Text("Content for section A")
+ with AccordionItem("Section B"):
+ Text("Content for section B")
+```
+
+| Prop (Accordion) | Type | Description |
+|------------------|------|-------------|
+| `multiple` | `bool` | Allow multiple items open simultaneously |
+
+### Dialog
+
+A modal overlay that appears above the page content. Pair it with a trigger (like a `Button`) to open and close it.
+
+```python
+from prefab_ui.components import Dialog, Column, Heading, Text
+
+with Dialog(title="Confirm Delete"):
+ with Column(gap=2):
+ Text("Are you sure you want to delete this item?")
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `title` | `str` | Dialog title in the header |
+| `description` | `str` | Subtitle below the title |
+
+### Pages / Page
+
+Multi-page navigation within a single app. `Pages` renders one `Page` at a time, controlled by state. Useful for multi-step workflows and wizards.
+
+```python
+from prefab_ui.app import set_initial_state
+from prefab_ui.components import Pages, Page, Text, Button
+from prefab_ui.actions import SetState
+
+state = set_initial_state(page="welcome")
+
+with Pages(active_page=state.page):
+ with Page("welcome"):
+ Text("Welcome!")
+ Button("Next", on_click=SetState("page", "setup"))
+ with Page("setup"):
+ Text("Configure your settings")
+```
+
+| Prop (Pages) | Type | Description |
+|-------------|------|-------------|
+| `active_page` | `str \| Rx` | The label of the currently visible page |
+
+## Control Flow
+
+Control flow components conditionally show or iterate over children based on reactive state. They evaluate in the browser, so changes are instant.
+
+### If / Elif / Else
+
+Conditionally render content based on state values. `If` evaluates an `Rx` expression; `Elif` and `Else` follow the same pattern as Python's branching.
+
+```python
+from prefab_ui.app import set_initial_state
+from prefab_ui.components import If, Elif, Else, Text, Select
+
+state = set_initial_state(role="viewer")
+
+Select(name="role", options=["viewer", "editor", "admin"])
+with If(state.role == "admin"):
+ Text("Full access")
+with Elif(state.role == "editor"):
+ Text("Edit access")
+with Else():
+ Text("Read-only access")
+```
+
+### ForEach
+
+Iterates over a state array and renders children for each item. The loop variable is an `Rx` proxy scoped to the current item, so `item.name` resolves at render time.
+
+```python
+from prefab_ui.components import ForEach, Row, Text, Badge
+
+with ForEach("users") as user:
+ with Row(gap=2):
+ Text(user.name)
+ Badge(user.role)
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| first arg | `str` | The state key containing the array |
+
+## Feedback
+
+### Alert
+
+A callout box for important messages. Supports variants to signal severity.
+
+```python
+from prefab_ui.components import Alert
+
+Alert(title="Deployment complete", variant="success")
+Alert(
+ title="Rate limit approaching",
+ description="Current usage is at 85% of your plan limit.",
+ variant="warning",
+)
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `title` | `str` | Alert heading |
+| `description` | `str` | Body text |
+| `variant` | `str` | `"default"`, `"success"`, `"warning"`, `"destructive"` |
+
+### Progress
+
+A horizontal progress bar. Pass a `value` between 0 and 100. Supports reactive values so the bar updates as state changes.
+
+```python
+from prefab_ui.components import Progress
+
+Progress(value=75)
+```
+
+| Prop | Type | Description |
+|------|------|-------------|
+| `value` | `int \| Rx` | Progress percentage (0-100) |
+
+### Loader
+
+A spinning indicator for loading states. Takes no required props — just drop it in and it spins.
+
+```python
+from prefab_ui.components import Loader
+
+Loader()
+```
+
+---
+
+For the complete API — including additional components like `Metric`, `Calendar`, `Markdown`, `Embed`, and advanced chart types — see the [Prefab component reference](https://prefab.prefect.io/docs/components).
diff --git a/docs/apps/patterns.mdx b/docs/apps/patterns.mdx
index cf48e0fb5..9822ccc1f 100644
--- a/docs/apps/patterns.mdx
+++ b/docs/apps/patterns.mdx
@@ -159,7 +159,7 @@ def employee_directory() -> PrefabApp:
DataTableColumn(key="location", header="Office", sortable=True),
],
rows=employees,
- searchable=True,
+ search=True,
paginated=True,
page_size=15,
)
diff --git a/docs/docs.json b/docs/docs.json
index d08bdf89f..940284fac 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -194,8 +194,10 @@
"apps/overview",
"apps/prefab",
"apps/interactive-apps",
+ "apps/components",
"apps/patterns",
"apps/development",
+ "apps/architecture",
"apps/low-level"
]
},
diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx
index 3f240cec1..5f3f56b38 100644
--- a/docs/getting-started/quickstart.mdx
+++ b/docs/getting-started/quickstart.mdx
@@ -3,7 +3,7 @@ title: Quickstart
icon: rocket-launch
---
-Welcome! This guide will help you quickly set up FastMCP, run your first MCP server, and deploy a server to Prefect Horizon.
+Welcome! This guide will help you quickly set up FastMCP, run your first MCP server, give it a visual UI, and deploy it to Prefect Horizon.
If you haven't already installed FastMCP, follow the [installation instructions](/getting-started/installation).
@@ -117,6 +117,34 @@ Note that:
- We must enter a client context (`async with client:`) before using the client
- You can make multiple client calls within the same context
+## Give Your Tool a UI
+
+Tools normally return text, but any tool can return an interactive UI instead. Add `app=True` to your tool decorator and return a [Prefab](https://prefab.prefect.io) component — the host renders it as a chart, table, form, or any other visual element right in the conversation. This requires the `apps` extra (`pip install "fastmcp[apps]"`).
+
+The `app=True` flag tells FastMCP to wire up the renderer and protocol metadata automatically. The tool still works like any other MCP tool — it receives arguments and returns a result — but the result is a component tree that the host displays visually instead of as plain text.
+
+```python my_server.py
+from prefab_ui.app import PrefabApp
+from prefab_ui.components import Column, Heading, Text, Badge, Row
+from fastmcp import FastMCP
+
+mcp = FastMCP("My MCP Server")
+
+
+@mcp.tool(app=True)
+def greet(name: str) -> PrefabApp:
+ """Greet someone with a visual card."""
+ with Column(gap=4, css_class="p-6") as view:
+ Heading(f"Hello, {name}!")
+ with Row(gap=2, align="center"):
+ Text("Status")
+ Badge("Greeted", variant="success")
+
+ return PrefabApp(view=view)
+```
+
+You can preview app tools locally with `fastmcp dev apps my_server.py` — no MCP host required. See the [Apps overview](/apps/overview) for the full guide, including state management, forms, charts, and server-connected interactivity.
+
## Deploy to Prefect Horizon
[Prefect Horizon](https://horizon.prefect.io) is the enterprise MCP platform built by the FastMCP team at [Prefect](https://www.prefect.io). It provides managed hosting, authentication, access control, and observability for MCP servers.
diff --git a/examples/apps/approvals/approvals_server.py b/examples/apps/approvals/approvals_server.py
new file mode 100644
index 000000000..9eec6161a
--- /dev/null
+++ b/examples/apps/approvals/approvals_server.py
@@ -0,0 +1,332 @@
+"""Approval workflow — a FastMCPApp example with tabs, status badges, and action chaining.
+
+Demonstrates a multi-step interactive workflow:
+- @app.ui() entry point showing a pending approvals dashboard
+- @app.tool() backend tools that the UI calls via CallTool
+- @app.tool(model=True) for tools accessible from both model and UI
+- Tabs with filtered lists and counter badges
+- Action chaining: approve → update state → show toast
+
+Usage:
+ uv run python approvals_server.py
+"""
+
+from __future__ import annotations
+
+from prefab_ui.actions import SetState, ShowToast
+from prefab_ui.actions.mcp import CallTool
+from prefab_ui.app import PrefabApp, set_initial_state
+from prefab_ui.components import (
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ Column,
+ ForEach,
+ Heading,
+ If,
+ Muted,
+ Row,
+ Separator,
+ Tab,
+ Tabs,
+ Text,
+)
+from prefab_ui.rx import ERROR, RESULT, Rx
+
+from fastmcp import FastMCP, FastMCPApp
+
+# ---------------------------------------------------------------------------
+# Data
+# ---------------------------------------------------------------------------
+
+_requests: list[dict] = [
+ {
+ "id": "REQ-001",
+ "type": "expense",
+ "title": "Client dinner — Acme Corp",
+ "submitter": "Alice Chen",
+ "description": "Business dinner with Acme Corp stakeholders to discuss Q3 partnership.",
+ "amount": 284.50,
+ "status": "pending",
+ "created_at": "2026-03-18",
+ },
+ {
+ "id": "REQ-002",
+ "type": "access",
+ "title": "Production database read access",
+ "submitter": "Bob Martinez",
+ "description": "Need read access to prod DB for quarterly analytics report.",
+ "amount": None,
+ "status": "pending",
+ "created_at": "2026-03-19",
+ },
+ {
+ "id": "REQ-003",
+ "type": "time_off",
+ "title": "Vacation — Apr 7-11",
+ "submitter": "Carol Johnson",
+ "description": "Family vacation, all deliverables handed off to David.",
+ "amount": None,
+ "status": "approved",
+ "created_at": "2026-03-15",
+ },
+ {
+ "id": "REQ-004",
+ "type": "expense",
+ "title": "Conference registration — PyCon 2026",
+ "submitter": "David Kim",
+ "description": "PyCon US 2026 early-bird registration plus tutorial day.",
+ "amount": 650.00,
+ "status": "pending",
+ "created_at": "2026-03-20",
+ },
+ {
+ "id": "REQ-005",
+ "type": "access",
+ "title": "AWS staging account access",
+ "submitter": "Eva Mueller",
+ "description": "Staging environment access for load testing new API endpoints.",
+ "amount": None,
+ "status": "rejected",
+ "created_at": "2026-03-14",
+ },
+ {
+ "id": "REQ-006",
+ "type": "expense",
+ "title": "Team offsite lunch",
+ "submitter": "Frank Okafor",
+ "description": "Catering for 12-person engineering offsite planning session.",
+ "amount": 420.00,
+ "status": "pending",
+ "created_at": "2026-03-21",
+ },
+ {
+ "id": "REQ-007",
+ "type": "time_off",
+ "title": "Personal day — Mar 28",
+ "submitter": "Grace Liu",
+ "description": "Personal appointment, will be available on Slack for emergencies.",
+ "amount": None,
+ "status": "pending",
+ "created_at": "2026-03-20",
+ },
+ {
+ "id": "REQ-008",
+ "type": "expense",
+ "title": "Software license — Figma annual",
+ "submitter": "Hassan Ali",
+ "description": "Annual Figma Professional license renewal for design team.",
+ "amount": 144.00,
+ "status": "approved",
+ "created_at": "2026-03-12",
+ },
+]
+
+
+def _by_status(status: str) -> list[dict]:
+ return [r for r in _requests if r["status"] == status]
+
+
+def _find_request(request_id: str) -> dict | None:
+ for r in _requests:
+ if r["id"] == request_id:
+ return r
+ return None
+
+
+# ---------------------------------------------------------------------------
+# App
+# ---------------------------------------------------------------------------
+
+app = FastMCPApp("Approvals")
+
+
+def _all_lists() -> dict[str, list[dict]]:
+ """Return state updates for all three status lists."""
+ return {
+ "pending_requests": _by_status("pending"),
+ "approved_requests": _by_status("approved"),
+ "rejected_requests": _by_status("rejected"),
+ }
+
+
+@app.tool()
+def approve_request(request_id: str) -> dict[str, list[dict]]:
+ """Approve a pending request and return updated lists."""
+ req = _find_request(request_id)
+ if req is None:
+ raise ValueError(f"Request {request_id} not found")
+ if req["status"] != "pending":
+ raise ValueError(f"Request {request_id} is already {req['status']}")
+ req["status"] = "approved"
+ return _all_lists()
+
+
+@app.tool()
+def reject_request(request_id: str) -> dict[str, list[dict]]:
+ """Reject a pending request and return updated lists."""
+ req = _find_request(request_id)
+ if req is None:
+ raise ValueError(f"Request {request_id} not found")
+ if req["status"] != "pending":
+ raise ValueError(f"Request {request_id} is already {req['status']}")
+ req["status"] = "rejected"
+ return _all_lists()
+
+
+@app.tool()
+def add_comment(request_id: str, comment: str) -> dict:
+ """Add a comment to a request. Returns the updated request."""
+ req = _find_request(request_id)
+ if req is None:
+ raise ValueError(f"Request {request_id} not found")
+ comments = req.setdefault("comments", [])
+ comments.append(comment)
+ return req
+
+
+@app.tool(model=True)
+def get_request_details(request_id: str) -> dict:
+ """Get full details for a single request. Available to both model and UI."""
+ req = _find_request(request_id)
+ if req is None:
+ raise ValueError(f"Request {request_id} not found")
+ return req
+
+
+@app.tool()
+def list_requests(status: str | None = None) -> list[dict]:
+ """List requests, optionally filtered by status."""
+ if status is not None:
+ return _by_status(status)
+ return list(_requests)
+
+
+def _update_all_lists() -> list:
+ """Actions to update all three status lists from a tool result."""
+ return [
+ SetState("pending_requests", RESULT.pending_requests),
+ SetState("approved_requests", RESULT.approved_requests),
+ SetState("rejected_requests", RESULT.rejected_requests),
+ ]
+
+
+def _build_request_card(
+ item: Rx,
+ *,
+ status_variant: str = "warning",
+ show_actions: bool = False,
+) -> None:
+ """Build a card for a single request inside a ForEach context."""
+ request_id = str(item.id)
+
+ with Card():
+ with CardHeader():
+ with Row(gap=2, align="center", justify="between"):
+ CardTitle(item.title)
+ Badge(item.status, variant=status_variant)
+ with CardContent(css_class="space-y-2"):
+ with Row(gap=2, align="center"):
+ Badge(item.type, variant="secondary")
+ Text(item.submitter, css_class="font-medium")
+ Muted(item.created_at)
+
+ with If(item.amount):
+ Text(item.amount.currency(), css_class="text-lg font-semibold")
+
+ Muted(item.description)
+
+ if show_actions:
+ Separator()
+ with Row(gap=2):
+ Button(
+ "Approve",
+ variant="default",
+ on_click=CallTool(
+ approve_request,
+ arguments={"request_id": request_id},
+ on_success=_update_all_lists()
+ + [
+ ShowToast(
+ "Request approved",
+ variant="success",
+ ),
+ ],
+ on_error=ShowToast(
+ ERROR,
+ variant="error",
+ ),
+ ),
+ )
+ Button(
+ "Reject",
+ variant="destructive",
+ on_click=CallTool(
+ reject_request,
+ arguments={"request_id": request_id},
+ on_success=_update_all_lists()
+ + [
+ ShowToast(
+ "Request rejected",
+ variant="warning",
+ ),
+ ],
+ on_error=ShowToast(
+ ERROR,
+ variant="error",
+ ),
+ ),
+ )
+
+
+@app.ui()
+def approval_dashboard() -> PrefabApp:
+ """Open the approval dashboard. The model calls this to launch the app."""
+ state = set_initial_state(
+ pending_requests=_by_status("pending"),
+ approved_requests=_by_status("approved"),
+ rejected_requests=_by_status("rejected"),
+ )
+
+ pending_count = state.pending_requests.length()
+ approved_count = state.approved_requests.length()
+ rejected_count = state.rejected_requests.length()
+
+ with Column(gap=6, css_class="p-6") as view:
+ with Row(gap=3, align="center"):
+ Heading("Approval Dashboard")
+ Badge(pending_count, variant="warning")
+ Muted("pending")
+
+ with Tabs(value="pending"):
+ with Tab(title="Pending"):
+ with If(pending_count):
+ with ForEach("pending_requests") as item:
+ _build_request_card(item, show_actions=True)
+ with If(~pending_count):
+ Muted("No pending requests.")
+
+ with Tab(title="Approved"):
+ with If(approved_count):
+ with ForEach("approved_requests") as item:
+ _build_request_card(item, status_variant="success")
+ with If(~approved_count):
+ Muted("No approved requests.")
+
+ with Tab(title="Rejected"):
+ with If(rejected_count):
+ with ForEach("rejected_requests") as item:
+ _build_request_card(item, status_variant="destructive")
+ with If(~rejected_count):
+ Muted("No rejected requests.")
+
+ return PrefabApp(view=view)
+
+
+mcp = FastMCP("Approvals Server", providers=[app])
+
+if __name__ == "__main__":
+ mcp.run(transport="http")
diff --git a/examples/apps/contacts/contacts_server.py b/examples/apps/contacts/contacts_server.py
index 6483af748..39d225ef0 100644
--- a/examples/apps/contacts/contacts_server.py
+++ b/examples/apps/contacts/contacts_server.py
@@ -8,8 +8,7 @@ Demonstrates the full FastMCPApp stack:
- Manual form construction with the context-manager pattern
Usage:
- uv run python contacts_server.py # HTTP (default)
- uv run python contacts_server.py --stdio # stdio for MCP clients
+ uv run python contacts_server.py
"""
from __future__ import annotations
@@ -18,7 +17,7 @@ from typing import Literal
from prefab_ui.actions import SetState, ShowToast
from prefab_ui.actions.mcp import CallTool
-from prefab_ui.app import PrefabApp
+from prefab_ui.app import PrefabApp, set_initial_state
from prefab_ui.components import (
Badge,
Button,
@@ -32,7 +31,7 @@ from prefab_ui.components import (
Separator,
Text,
)
-from prefab_ui.rx import RESULT
+from prefab_ui.rx import ERROR, RESULT, STATE
from pydantic import BaseModel, Field
from fastmcp import FastMCP, FastMCPApp
@@ -103,6 +102,8 @@ def list_contacts() -> list[dict]:
@app.ui()
def contact_manager() -> PrefabApp:
"""Open the contact manager. The model calls this to launch the app."""
+ set_initial_state(contacts=list(_contacts))
+
with Column(gap=6, css_class="p-6") as view:
Heading("Contacts")
@@ -123,7 +124,7 @@ def contact_manager() -> PrefabApp:
SetState("contacts", RESULT),
ShowToast("Contact saved!", variant="success"),
],
- on_error=ShowToast("{{ $error }}", variant="error"),
+ on_error=ShowToast(ERROR, variant="error"),
),
)
@@ -133,17 +134,14 @@ def contact_manager() -> PrefabApp:
with Form(
on_submit=CallTool(
search_contacts,
- arguments={"query": "{{ query }}"},
+ arguments={"query": STATE.query},
on_success=SetState("contacts", RESULT),
)
):
Input(name="query", placeholder="Search by name or email...")
Button("Search")
- return PrefabApp(
- view=view,
- state={"contacts": list(_contacts)},
- )
+ return PrefabApp(view=view)
mcp = FastMCP("Contacts Server", providers=[app])
diff --git a/examples/apps/datatable_server.py b/examples/apps/datatable_server.py
index ee10a1cc8..35776eeee 100644
--- a/examples/apps/datatable_server.py
+++ b/examples/apps/datatable_server.py
@@ -57,7 +57,7 @@ def team_directory(department: str | None = None) -> Column:
DataTableColumn(key="location", header="Location", sortable=True),
],
rows=rows,
- searchable=True,
+ search=True,
paginated=True,
)
return view
diff --git a/examples/apps/explorer/explorer_server.py b/examples/apps/explorer/explorer_server.py
new file mode 100644
index 000000000..b156e3393
--- /dev/null
+++ b/examples/apps/explorer/explorer_server.py
@@ -0,0 +1,588 @@
+"""Data explorer — a FastMCPApp example with tables, charts, and filtering.
+
+Demonstrates the full FastMCPApp stack:
+- @app.ui() entry point with a tabbed data exploration interface
+- @app.tool() backend tools for analysis, summaries, and filtering
+- DataTable with sorting, search, and pagination
+- BarChart and PieChart for data visualization
+- Metric cards for summary statistics
+- Select-driven filtering with CallTool
+- State management with set_initial_state() and Rx()
+
+Usage:
+ uv run python explorer_server.py # HTTP (default)
+ uv run python explorer_server.py --stdio # stdio for MCP clients
+"""
+
+from __future__ import annotations
+
+from prefab_ui.actions import SetState, ShowToast
+from prefab_ui.actions.mcp import CallTool
+from prefab_ui.app import PrefabApp, set_initial_state
+from prefab_ui.components import (
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ Column,
+ DataTable,
+ DataTableColumn,
+ Grid,
+ Heading,
+ Metric,
+ Muted,
+ Row,
+ Select,
+ SelectOption,
+ Separator,
+ Tab,
+ Tabs,
+ Text,
+)
+from prefab_ui.components.charts import BarChart, ChartSeries, PieChart
+from prefab_ui.rx import ERROR, RESULT, STATE
+
+from fastmcp import FastMCP, FastMCPApp
+
+# ---------------------------------------------------------------------------
+# Sample data
+# ---------------------------------------------------------------------------
+
+SALES_DATA: list[dict] = [
+ {
+ "date": "2025-01-05",
+ "product": "Widget A",
+ "region": "North",
+ "amount": 1200,
+ "quantity": 10,
+ },
+ {
+ "date": "2025-01-12",
+ "product": "Widget B",
+ "region": "South",
+ "amount": 850,
+ "quantity": 7,
+ },
+ {
+ "date": "2025-01-18",
+ "product": "Gadget X",
+ "region": "East",
+ "amount": 2300,
+ "quantity": 15,
+ },
+ {
+ "date": "2025-01-25",
+ "product": "Gadget Y",
+ "region": "West",
+ "amount": 1750,
+ "quantity": 12,
+ },
+ {
+ "date": "2025-02-02",
+ "product": "Widget A",
+ "region": "East",
+ "amount": 1400,
+ "quantity": 11,
+ },
+ {
+ "date": "2025-02-09",
+ "product": "Widget B",
+ "region": "North",
+ "amount": 920,
+ "quantity": 8,
+ },
+ {
+ "date": "2025-02-15",
+ "product": "Gadget X",
+ "region": "South",
+ "amount": 2100,
+ "quantity": 14,
+ },
+ {
+ "date": "2025-02-22",
+ "product": "Gadget Y",
+ "region": "West",
+ "amount": 1600,
+ "quantity": 11,
+ },
+ {
+ "date": "2025-03-01",
+ "product": "Widget A",
+ "region": "South",
+ "amount": 1350,
+ "quantity": 10,
+ },
+ {
+ "date": "2025-03-08",
+ "product": "Widget B",
+ "region": "West",
+ "amount": 780,
+ "quantity": 6,
+ },
+ {
+ "date": "2025-03-14",
+ "product": "Gadget X",
+ "region": "North",
+ "amount": 2500,
+ "quantity": 17,
+ },
+ {
+ "date": "2025-03-21",
+ "product": "Gadget Y",
+ "region": "East",
+ "amount": 1900,
+ "quantity": 13,
+ },
+ {
+ "date": "2025-04-03",
+ "product": "Widget A",
+ "region": "West",
+ "amount": 1100,
+ "quantity": 9,
+ },
+ {
+ "date": "2025-04-10",
+ "product": "Widget B",
+ "region": "East",
+ "amount": 960,
+ "quantity": 8,
+ },
+ {
+ "date": "2025-04-17",
+ "product": "Gadget X",
+ "region": "South",
+ "amount": 2400,
+ "quantity": 16,
+ },
+ {
+ "date": "2025-04-24",
+ "product": "Gadget Y",
+ "region": "North",
+ "amount": 1850,
+ "quantity": 12,
+ },
+ {
+ "date": "2025-05-01",
+ "product": "Widget A",
+ "region": "North",
+ "amount": 1500,
+ "quantity": 12,
+ },
+ {
+ "date": "2025-05-08",
+ "product": "Widget B",
+ "region": "South",
+ "amount": 890,
+ "quantity": 7,
+ },
+ {
+ "date": "2025-05-15",
+ "product": "Gadget X",
+ "region": "West",
+ "amount": 2200,
+ "quantity": 15,
+ },
+ {
+ "date": "2025-05-22",
+ "product": "Gadget Y",
+ "region": "East",
+ "amount": 1700,
+ "quantity": 11,
+ },
+ {
+ "date": "2025-06-05",
+ "product": "Widget A",
+ "region": "East",
+ "amount": 1300,
+ "quantity": 10,
+ },
+ {
+ "date": "2025-06-12",
+ "product": "Widget B",
+ "region": "North",
+ "amount": 1050,
+ "quantity": 9,
+ },
+ {
+ "date": "2025-06-19",
+ "product": "Gadget X",
+ "region": "North",
+ "amount": 2600,
+ "quantity": 18,
+ },
+ {
+ "date": "2025-06-26",
+ "product": "Gadget Y",
+ "region": "South",
+ "amount": 1650,
+ "quantity": 11,
+ },
+ {
+ "date": "2025-07-03",
+ "product": "Widget A",
+ "region": "South",
+ "amount": 1450,
+ "quantity": 11,
+ },
+ {
+ "date": "2025-07-10",
+ "product": "Widget B",
+ "region": "West",
+ "amount": 830,
+ "quantity": 7,
+ },
+ {
+ "date": "2025-07-17",
+ "product": "Gadget X",
+ "region": "East",
+ "amount": 2350,
+ "quantity": 16,
+ },
+ {
+ "date": "2025-07-24",
+ "product": "Gadget Y",
+ "region": "West",
+ "amount": 1800,
+ "quantity": 12,
+ },
+ {
+ "date": "2025-08-01",
+ "product": "Widget A",
+ "region": "West",
+ "amount": 1250,
+ "quantity": 10,
+ },
+ {
+ "date": "2025-08-08",
+ "product": "Widget B",
+ "region": "East",
+ "amount": 970,
+ "quantity": 8,
+ },
+ {
+ "date": "2025-08-15",
+ "product": "Gadget X",
+ "region": "South",
+ "amount": 2450,
+ "quantity": 16,
+ },
+ {
+ "date": "2025-08-22",
+ "product": "Gadget Y",
+ "region": "North",
+ "amount": 1950,
+ "quantity": 13,
+ },
+]
+
+REGIONS = ["All", "North", "South", "East", "West"]
+PRODUCTS = ["All", "Widget A", "Widget B", "Gadget X", "Gadget Y"]
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _filter_rows(
+ rows: list[dict],
+ region: str = "All",
+ product: str = "All",
+) -> list[dict]:
+ filtered = rows
+ if region != "All":
+ filtered = [r for r in filtered if r["region"] == region]
+ if product != "All":
+ filtered = [r for r in filtered if r["product"] == product]
+ return filtered
+
+
+def _compute_summary(rows: list[dict]) -> dict:
+ if not rows:
+ return {
+ "count": 0,
+ "total_amount": 0,
+ "avg_amount": 0,
+ "min_amount": 0,
+ "max_amount": 0,
+ "total_quantity": 0,
+ }
+ amounts = [r["amount"] for r in rows]
+ return {
+ "count": len(rows),
+ "total_amount": sum(amounts),
+ "avg_amount": round(sum(amounts) / len(amounts)),
+ "min_amount": min(amounts),
+ "max_amount": max(amounts),
+ "total_quantity": sum(r["quantity"] for r in rows),
+ }
+
+
+def _aggregate_by(rows: list[dict], key: str) -> list[dict]:
+ totals: dict[str, int] = {}
+ for row in rows:
+ label = row[key]
+ totals[label] = totals.get(label, 0) + row["amount"]
+ return [{key: label, "amount": total} for label, total in sorted(totals.items())]
+
+
+# ---------------------------------------------------------------------------
+# App
+# ---------------------------------------------------------------------------
+
+app = FastMCPApp("Data Explorer")
+
+
+@app.tool()
+def analyze_data(region: str = "All", product: str = "All") -> dict:
+ """Filter and analyze sales data. Returns rows, summary, and chart data."""
+ filtered = _filter_rows(SALES_DATA, region, product)
+ return {
+ "rows": filtered,
+ "summary": _compute_summary(filtered),
+ "by_region": _aggregate_by(filtered, "region"),
+ "by_product": _aggregate_by(filtered, "product"),
+ }
+
+
+@app.tool(model=True)
+def get_summary() -> dict:
+ """Return summary statistics for the full dataset."""
+ return _compute_summary(SALES_DATA)
+
+
+@app.tool()
+def filter_data(region: str = "All", product: str = "All") -> list[dict]:
+ """Filter sales data by region and/or product."""
+ return _filter_rows(SALES_DATA, region, product)
+
+
+@app.ui()
+def data_explorer() -> PrefabApp:
+ """Open the data explorer. Browse, filter, and visualize sales data."""
+
+ initial = analyze_data()
+ state = set_initial_state(
+ rows=initial["rows"],
+ summary=initial["summary"],
+ by_region=initial["by_region"],
+ by_product=initial["by_product"],
+ selected_region="All",
+ selected_product="All",
+ loading=False,
+ )
+
+ with Column(gap=6, css_class="p-6") as view:
+ Heading("Sales Data Explorer")
+ Muted(f"{len(SALES_DATA)} records loaded")
+
+ Separator()
+
+ # ----- Filters -----
+ with Row(gap=4, align="center"):
+ Text("Filters", css_class="font-semibold")
+
+ with Select(
+ name="selected_region",
+ placeholder="Region",
+ value="All",
+ on_change=[
+ SetState("loading", True),
+ CallTool(
+ analyze_data,
+ arguments={
+ "region": STATE.selected_region,
+ "product": STATE.selected_product,
+ },
+ on_success=[
+ SetState("rows", RESULT.rows),
+ SetState("summary", RESULT.summary),
+ SetState("by_region", RESULT.by_region),
+ SetState("by_product", RESULT.by_product),
+ SetState("loading", False),
+ ShowToast("Data updated", variant="success"),
+ ],
+ on_error=[
+ SetState("loading", False),
+ ShowToast(ERROR, variant="error"),
+ ],
+ ),
+ ],
+ ):
+ for region in REGIONS:
+ SelectOption(value=region, label=region)
+
+ with Select(
+ name="selected_product",
+ placeholder="Product",
+ value="All",
+ on_change=[
+ SetState("loading", True),
+ CallTool(
+ analyze_data,
+ arguments={
+ "region": STATE.selected_region,
+ "product": STATE.selected_product,
+ },
+ on_success=[
+ SetState("rows", RESULT.rows),
+ SetState("summary", RESULT.summary),
+ SetState("by_region", RESULT.by_region),
+ SetState("by_product", RESULT.by_product),
+ SetState("loading", False),
+ ShowToast("Data updated", variant="success"),
+ ],
+ on_error=[
+ SetState("loading", False),
+ ShowToast(ERROR, variant="error"),
+ ],
+ ),
+ ],
+ ):
+ for product in PRODUCTS:
+ SelectOption(value=product, label=product)
+
+ Button(
+ state.loading.then("Loading...", "Reset"),
+ disabled=state.loading,
+ on_click=[
+ SetState("selected_region", "All"),
+ SetState("selected_product", "All"),
+ SetState("loading", True),
+ CallTool(
+ analyze_data,
+ arguments={"region": "All", "product": "All"},
+ on_success=[
+ SetState("rows", RESULT.rows),
+ SetState("summary", RESULT.summary),
+ SetState("by_region", RESULT.by_region),
+ SetState("by_product", RESULT.by_product),
+ SetState("loading", False),
+ ],
+ on_error=[
+ SetState("loading", False),
+ ShowToast(ERROR, variant="error"),
+ ],
+ ),
+ ],
+ )
+
+ Separator()
+
+ # ----- Tabs -----
+ with Tabs():
+ # ---- Summary ----
+ with Tab("Summary"):
+ with Grid(columns=3, gap=4):
+ with Card():
+ with CardContent():
+ Metric(
+ label="Total Revenue",
+ value=state.summary.total_amount,
+ )
+ with Card():
+ with CardContent():
+ Metric(
+ label="Average Sale",
+ value=state.summary.avg_amount,
+ )
+ with Card():
+ with CardContent():
+ Metric(
+ label="Total Quantity",
+ value=state.summary.total_quantity,
+ )
+
+ with Grid(columns=3, gap=4, css_class="mt-4"):
+ with Card():
+ with CardContent():
+ Metric(
+ label="Transactions",
+ value=state.summary.count,
+ )
+ with Card():
+ with CardContent():
+ Metric(
+ label="Min Sale",
+ value=state.summary.min_amount,
+ )
+ with Card():
+ with CardContent():
+ Metric(
+ label="Max Sale",
+ value=state.summary.max_amount,
+ )
+
+ with Row(gap=2, css_class="mt-4"):
+ Badge(f"Region: {STATE.selected_region}")
+ Badge(f"Product: {STATE.selected_product}")
+
+ # ---- Table ----
+ with Tab("Table"):
+ DataTable(
+ columns=[
+ DataTableColumn(key="date", header="Date", sortable=True),
+ DataTableColumn(key="product", header="Product", sortable=True),
+ DataTableColumn(key="region", header="Region", sortable=True),
+ DataTableColumn(
+ key="amount", header="Amount ($)", sortable=True
+ ),
+ DataTableColumn(key="quantity", header="Qty", sortable=True),
+ ],
+ rows="{{ rows }}",
+ search=True,
+ paginated=True,
+ page_size=10,
+ )
+
+ # ---- Charts ----
+ with Tab("Charts"):
+ with Grid(columns=2, gap=6):
+ with Column(gap=2):
+ Heading("Revenue by Region", level=3)
+ BarChart(
+ data=state.by_region,
+ series=[ChartSeries(data_key="amount", label="Revenue")],
+ x_axis="region",
+ show_legend=True,
+ )
+
+ with Column(gap=2):
+ Heading("Revenue by Product", level=3)
+ BarChart(
+ data=state.by_product,
+ series=[ChartSeries(data_key="amount", label="Revenue")],
+ x_axis="product",
+ show_legend=True,
+ )
+
+ Separator(css_class="my-4")
+
+ with Grid(columns=2, gap=6):
+ with Column(gap=2):
+ Heading("Region Breakdown", level=3)
+ PieChart(
+ data=state.by_region,
+ data_key="amount",
+ name_key="region",
+ show_legend=True,
+ inner_radius=60,
+ )
+
+ with Column(gap=2):
+ Heading("Product Breakdown", level=3)
+ PieChart(
+ data=state.by_product,
+ data_key="amount",
+ name_key="product",
+ show_legend=True,
+ inner_radius=60,
+ )
+
+ return PrefabApp(view=view)
+
+
+mcp = FastMCP("Data Explorer", providers=[app])
+
+if __name__ == "__main__":
+ mcp.run(transport="http")
diff --git a/examples/apps/inventory/inventory_server.py b/examples/apps/inventory/inventory_server.py
new file mode 100644
index 000000000..d0ce358af
--- /dev/null
+++ b/examples/apps/inventory/inventory_server.py
@@ -0,0 +1,444 @@
+"""Inventory tracker -- a FastMCPApp example with CRUD operations and rich UI.
+
+Demonstrates the full FastMCPApp stack:
+- @app.ui() entry point that the model calls to open the app
+- @app.tool() backend tools for add, update, delete, and search
+- DataTable with sortable columns and built-in search
+- Form.from_model() for auto-generated Pydantic model forms
+- Tabs, Select filtering, ForEach results, and Toast notifications
+- State management with set_initial_state() and Rx()
+
+Usage:
+ uv run python inventory_server.py # HTTP (default)
+ uv run python inventory_server.py --stdio # stdio for MCP clients
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Literal
+
+from prefab_ui.actions import SetState, ShowToast
+from prefab_ui.actions.mcp import CallTool
+from prefab_ui.app import PrefabApp, set_initial_state
+from prefab_ui.components import (
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ Column,
+ DataTable,
+ DataTableColumn,
+ ForEach,
+ Form,
+ Grid,
+ Heading,
+ Input,
+ Muted,
+ Row,
+ Select,
+ SelectOption,
+ Separator,
+ Tab,
+ Tabs,
+ Text,
+)
+from prefab_ui.rx import ERROR, RESULT, STATE, Rx
+from pydantic import BaseModel, Field
+
+from fastmcp import FastMCP, FastMCPApp
+
+# ---------------------------------------------------------------------------
+# Data store
+# ---------------------------------------------------------------------------
+
+_next_id = 11
+
+_inventory: list[dict] = [
+ {
+ "id": 1,
+ "name": "Wireless Mouse",
+ "category": "Electronics",
+ "quantity": 45,
+ "price": 29.99,
+ "last_updated": "2026-03-20",
+ },
+ {
+ "id": 2,
+ "name": "Mechanical Keyboard",
+ "category": "Electronics",
+ "quantity": 32,
+ "price": 89.99,
+ "last_updated": "2026-03-19",
+ },
+ {
+ "id": 3,
+ "name": "USB-C Hub",
+ "category": "Electronics",
+ "quantity": 18,
+ "price": 49.99,
+ "last_updated": "2026-03-18",
+ },
+ {
+ "id": 4,
+ "name": "A4 Copy Paper (500 sheets)",
+ "category": "Office Supplies",
+ "quantity": 200,
+ "price": 8.50,
+ "last_updated": "2026-03-21",
+ },
+ {
+ "id": 5,
+ "name": "Ballpoint Pens (box)",
+ "category": "Office Supplies",
+ "quantity": 150,
+ "price": 12.00,
+ "last_updated": "2026-03-20",
+ },
+ {
+ "id": 6,
+ "name": "Sticky Notes (pack)",
+ "category": "Office Supplies",
+ "quantity": 85,
+ "price": 5.99,
+ "last_updated": "2026-03-17",
+ },
+ {
+ "id": 7,
+ "name": "Standing Desk",
+ "category": "Furniture",
+ "quantity": 8,
+ "price": 499.00,
+ "last_updated": "2026-03-15",
+ },
+ {
+ "id": 8,
+ "name": "Ergonomic Chair",
+ "category": "Furniture",
+ "quantity": 12,
+ "price": 349.00,
+ "last_updated": "2026-03-16",
+ },
+ {
+ "id": 9,
+ "name": "Monitor Arm",
+ "category": "Furniture",
+ "quantity": 25,
+ "price": 79.99,
+ "last_updated": "2026-03-22",
+ },
+ {
+ "id": 10,
+ "name": "Webcam HD",
+ "category": "Electronics",
+ "quantity": 60,
+ "price": 69.99,
+ "last_updated": "2026-03-21",
+ },
+]
+
+CATEGORIES = ["All", "Electronics", "Office Supplies", "Furniture"]
+
+
+# ---------------------------------------------------------------------------
+# Pydantic model for add-item form
+# ---------------------------------------------------------------------------
+
+
+class NewItem(BaseModel):
+ name: str = Field(title="Item Name", min_length=1)
+ category: Literal["Electronics", "Office Supplies", "Furniture"] = Field(
+ title="Category",
+ default="Electronics",
+ )
+ quantity: int = Field(title="Quantity", ge=0, default=1)
+ price: float = Field(title="Unit Price ($)", ge=0.0, default=0.0)
+
+
+# ---------------------------------------------------------------------------
+# App and tools
+# ---------------------------------------------------------------------------
+
+app = FastMCPApp("Inventory")
+
+
+@app.tool()
+def add_item(data: NewItem) -> list[dict]:
+ """Add a new item to inventory and return the full list."""
+ global _next_id
+ item = {
+ "id": _next_id,
+ "name": data.name,
+ "category": data.category,
+ "quantity": data.quantity,
+ "price": data.price,
+ "last_updated": datetime.now().strftime("%Y-%m-%d"),
+ }
+ _next_id += 1
+ _inventory.append(item)
+ return list(_inventory)
+
+
+@app.tool()
+def update_quantity(item_id: int, delta: int) -> list[dict]:
+ """Adjust an item's quantity by delta (+/-) and return the full list."""
+ for item in _inventory:
+ if item["id"] == item_id:
+ new_qty = max(0, item["quantity"] + delta)
+ item["quantity"] = new_qty
+ item["last_updated"] = datetime.now().strftime("%Y-%m-%d")
+ break
+ return list(_inventory)
+
+
+@app.tool()
+def delete_item(item_id: int) -> list[dict]:
+ """Remove an item by ID and return the remaining inventory."""
+ for i, item in enumerate(_inventory):
+ if item["id"] == item_id:
+ _inventory.pop(i)
+ break
+ return list(_inventory)
+
+
+@app.tool()
+def search_items(query: str) -> list[dict]:
+ """Search items by name (case-insensitive). Returns matching items."""
+ q = query.lower()
+ return [item for item in _inventory if q in item["name"].lower()]
+
+
+@app.tool()
+def filter_by_category(category: str) -> list[dict]:
+ """Filter inventory by category. Pass 'All' to show everything."""
+ if category == "All":
+ return list(_inventory)
+ return [item for item in _inventory if item["category"] == category]
+
+
+# ---------------------------------------------------------------------------
+# UI helpers
+# ---------------------------------------------------------------------------
+
+
+def _build_inventory_table() -> None:
+ """Render the main DataTable with all current items."""
+ DataTable(
+ columns=[
+ DataTableColumn(key="name", header="Name", sortable=True),
+ DataTableColumn(key="category", header="Category", sortable=True),
+ DataTableColumn(key="quantity", header="Qty", sortable=True),
+ DataTableColumn(key="price", header="Price ($)", sortable=True),
+ DataTableColumn(key="last_updated", header="Updated", sortable=True),
+ ],
+ rows=list(_inventory),
+ search=True,
+ paginated=True,
+ page_size=10,
+ )
+
+
+def _build_search_section() -> None:
+ """Render the search form with ForEach results."""
+ Heading("Search Items", level=3)
+ Muted("Search by name across all inventory items.")
+
+ with Form(
+ on_submit=CallTool(
+ search_items,
+ arguments={"query": STATE.query},
+ on_success=SetState("search_results", RESULT),
+ )
+ ):
+ Input(name="query", placeholder="Search by name...")
+ Button("Search")
+
+ with ForEach("search_results") as result:
+ with Card(css_class="mb-2"):
+ with CardContent():
+ with Row(gap=3, align="center"):
+ Text(result.name, css_class="font-medium")
+ Badge(result.category)
+ Text(result.quantity)
+ Muted("in stock")
+
+
+def _build_add_form() -> None:
+ """Render the add-item form using Form.from_model()."""
+ Heading("Add New Item", level=3)
+ Muted("Fill out the form below to add a new item to inventory.")
+
+ Form.from_model(
+ NewItem,
+ submit_label="Add Item",
+ on_submit=CallTool(
+ add_item,
+ on_success=[
+ SetState("recent_additions", RESULT),
+ ShowToast("Item added!", variant="success"),
+ ],
+ on_error=ShowToast(ERROR, variant="error"),
+ ),
+ )
+
+
+def _build_actions_section() -> None:
+ """Render category filter, quantity adjustment, and delete controls."""
+
+ # Category filter
+ Heading("Filter by Category", level=3)
+ Muted("Select a category to see matching items.")
+
+ with Form(
+ on_submit=CallTool(
+ filter_by_category,
+ arguments={"category": STATE.selected_category},
+ on_success=SetState("filtered_items", RESULT),
+ )
+ ):
+ with Select(name="selected_category", placeholder="Choose a category..."):
+ for cat in CATEGORIES:
+ SelectOption(cat, value=cat)
+ Button("Apply Filter")
+
+ with ForEach("filtered_items") as item:
+ with Row(gap=3, align="center", css_class="py-1"):
+ Badge(item.id, variant="outline")
+ Text(item.name, css_class="font-medium")
+ Badge(item.category)
+ Muted(item.quantity)
+
+ Separator()
+
+ # Quantity adjustment
+ Heading("Adjust Quantity", level=3)
+ Muted("Enter an item ID and use the buttons to adjust stock levels.")
+
+ Input(name="adjust_id", input_type="number", placeholder="Item ID (e.g. 1)")
+
+ with Row(gap=2):
+ Button(
+ "- 1",
+ variant="outline",
+ on_click=CallTool(
+ update_quantity,
+ arguments={"item_id": STATE.adjust_id, "delta": -1},
+ on_success=[
+ SetState("filtered_items", RESULT),
+ ShowToast("Quantity decreased", variant="default"),
+ ],
+ on_error=ShowToast(ERROR, variant="error"),
+ ),
+ )
+ Button(
+ "+ 1",
+ variant="outline",
+ on_click=CallTool(
+ update_quantity,
+ arguments={"item_id": STATE.adjust_id, "delta": 1},
+ on_success=[
+ SetState("filtered_items", RESULT),
+ ShowToast("Quantity increased", variant="default"),
+ ],
+ on_error=ShowToast(ERROR, variant="error"),
+ ),
+ )
+ Button(
+ "+ 10",
+ on_click=CallTool(
+ update_quantity,
+ arguments={"item_id": STATE.adjust_id, "delta": 10},
+ on_success=[
+ SetState("filtered_items", RESULT),
+ ShowToast("Restocked +10", variant="success"),
+ ],
+ on_error=ShowToast(ERROR, variant="error"),
+ ),
+ )
+
+ Separator()
+
+ # Delete
+ Heading("Delete Item", level=3)
+ Muted("Permanently remove an item by its ID.")
+
+ with Form(
+ on_submit=CallTool(
+ delete_item,
+ arguments={"item_id": STATE.delete_id},
+ on_success=[
+ SetState("filtered_items", RESULT),
+ ShowToast("Item deleted", variant="warning"),
+ ],
+ on_error=ShowToast(ERROR, variant="error"),
+ )
+ ):
+ Input(name="delete_id", input_type="number", placeholder="Item ID to delete")
+ Button("Delete", variant="destructive")
+
+
+# ---------------------------------------------------------------------------
+# Entry point UI
+# ---------------------------------------------------------------------------
+
+
+@app.ui()
+def inventory_manager() -> PrefabApp:
+ """Open the inventory manager. The model calls this to launch the app."""
+ set_initial_state(
+ search_results=[],
+ filtered_items=list(_inventory),
+ recent_additions=[],
+ selected_category="All",
+ adjust_id="",
+ delete_id="",
+ query="",
+ )
+
+ with Column(gap=6, css_class="p-6") as view:
+ with Row(gap=3, align="center"):
+ Heading("Inventory Tracker")
+ Badge(
+ Rx("filtered_items.length"),
+ variant="secondary",
+ )
+ Muted("items tracked")
+
+ Separator()
+
+ # Summary cards per category
+ with Grid(columns=3, gap=4):
+ for cat in ["Electronics", "Office Supplies", "Furniture"]:
+ count = sum(1 for it in _inventory if it["category"] == cat)
+ total_qty = sum(
+ it["quantity"] for it in _inventory if it["category"] == cat
+ )
+ with Card():
+ with CardContent():
+ Text(cat, css_class="font-medium")
+ Muted(f"{count} items, {total_qty} units")
+
+ with Tabs():
+ with Tab("All Items"):
+ _build_inventory_table()
+
+ with Tab("Search"):
+ _build_search_section()
+
+ with Tab("Add Item"):
+ _build_add_form()
+
+ with Tab("Actions"):
+ _build_actions_section()
+
+ return PrefabApp(view=view)
+
+
+# ---------------------------------------------------------------------------
+# Server
+# ---------------------------------------------------------------------------
+
+mcp = FastMCP("Inventory Server", providers=[app])
+
+if __name__ == "__main__":
+ mcp.run(transport="http")
diff --git a/examples/apps/patterns_server.py b/examples/apps/patterns_server.py
index 9b5b8ac79..7e15b3bb9 100644
--- a/examples/apps/patterns_server.py
+++ b/examples/apps/patterns_server.py
@@ -13,18 +13,15 @@ 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.app import PrefabApp, set_initial_state
from prefab_ui.components import (
Accordion,
AccordionItem,
Alert,
- AreaChart,
Badge,
- BarChart,
Button,
Card,
CardContent,
- ChartSeries,
Column,
DataTable,
DataTableColumn,
@@ -35,7 +32,6 @@ from prefab_ui.components import (
If,
Input,
Muted,
- PieChart,
Progress,
Row,
Select,
@@ -46,6 +42,8 @@ from prefab_ui.components import (
Text,
Textarea,
)
+from prefab_ui.components.charts import AreaChart, BarChart, ChartSeries, PieChart
+from prefab_ui.rx import ERROR
from fastmcp import FastMCP
@@ -297,7 +295,7 @@ def employee_directory() -> PrefabApp:
DataTableColumn(key="location", header="Office", sortable=True),
],
rows=EMPLOYEES,
- searchable=True,
+ search=True,
paginated=True,
page_size=15,
)
@@ -313,14 +311,16 @@ def employee_directory() -> PrefabApp:
@mcp.tool(app=True)
def contact_form() -> PrefabApp:
"""Show a form to create a new contact, with a live contact list below."""
+ set_initial_state(contacts=list(_contacts))
+
with Column(gap=6, css_class="p-6") as view:
Heading("Contacts")
- with ForEach("contacts"):
+ with ForEach("contacts") as item:
with Row(gap=2, align="center"):
- Text("{{ name }}", css_class="font-medium")
- Muted("{{ email }}")
- Badge("{{ category }}")
+ Text(item.name, css_class="font-medium")
+ Muted(item.email)
+ Badge(item.category)
Separator()
@@ -330,7 +330,7 @@ def contact_form() -> PrefabApp:
"save_contact",
result_key="contacts",
on_success=ShowToast("Contact saved!", variant="success"),
- on_error=ShowToast("{{ $error }}", variant="error"),
+ on_error=ShowToast(ERROR, variant="error"),
)
):
Input(name="name", label="Full Name", required=True)
@@ -343,7 +343,7 @@ def contact_form() -> PrefabApp:
Textarea(name="notes", label="Notes", placeholder="Optional notes...")
Button("Save Contact")
- return PrefabApp(view=view, state={"contacts": list(_contacts)})
+ return PrefabApp(view=view)
@mcp.tool
@@ -403,6 +403,8 @@ def system_status() -> PrefabApp:
@mcp.tool(app=True)
def feature_flags() -> PrefabApp:
"""Toggle feature flags with live preview."""
+ state = set_initial_state(dark_mode=False, beta_features=False)
+
with Column(gap=4, css_class="p-6") as view:
Heading("Feature Flags")
@@ -411,16 +413,16 @@ def feature_flags() -> PrefabApp:
Separator()
- with If("{{ dark_mode }}"):
+ with If(state.dark_mode):
Alert(title="Dark mode enabled", description="UI will use dark theme.")
- with If("{{ beta_features }}"):
+ with If(state.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})
+ return PrefabApp(view=view)
# ---------------------------------------------------------------------------
@@ -431,6 +433,8 @@ def feature_flags() -> PrefabApp:
@mcp.tool(app=True)
def project_overview() -> PrefabApp:
"""Show project details organized in tabs."""
+ set_initial_state(activity=PROJECT["activity"])
+
with Column(gap=4, css_class="p-6") as view:
Heading(PROJECT["name"])
@@ -451,12 +455,12 @@ def project_overview() -> PrefabApp:
)
with Tab("Activity"):
- with ForEach("activity"):
+ with ForEach("activity") as item:
with Row(gap=2):
- Muted("{{ timestamp }}")
- Text("{{ message }}")
+ Muted(item.timestamp)
+ Text(item.message)
- return PrefabApp(view=view, state={"activity": PROJECT["activity"]})
+ return PrefabApp(view=view)
# ---------------------------------------------------------------------------
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index 306242537..55f2d9b25 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -167,6 +167,32 @@ def _get_auth_context() -> tuple[bool, Any]:
return (False, get_access_token())
+def _is_model_visible(tool: Tool) -> bool:
+ """Check whether a tool should be visible to the model.
+
+ Tools registered via ``@app.tool()`` (without ``model=True``) have
+ ``meta["ui"]["visibility"] == ["app"]`` — they are callable by app UIs
+ but should not appear in the model's tool list.
+
+ Returns True (visible) when:
+ - The tool has no ``meta.ui.visibility`` (normal tools).
+ - ``"model"`` is in the visibility list (e.g. ``["model"]`` or ``["app", "model"]``).
+
+ Returns False when the visibility list exists and does not contain ``"model"``
+ (e.g. ``["app"]``).
+ """
+ meta = tool.meta
+ if not meta:
+ return True
+ ui = meta.get("ui")
+ if not isinstance(ui, dict):
+ return True
+ visibility = ui.get("visibility")
+ if not isinstance(visibility, list):
+ return True
+ return "model" in visibility
+
+
@asynccontextmanager
async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]:
"""Default lifespan context manager that does nothing.
@@ -537,9 +563,10 @@ class FastMCP(
)
# Get all tools, apply session transforms, then filter enabled
+ # and model-visible (app-only tools are hidden from the model).
tools = list(await super().list_tools())
tools = await apply_session_transforms(tools)
- tools = [t for t in tools if is_enabled(t)]
+ tools = [t for t in tools if is_enabled(t) and _is_model_visible(t)]
skip_auth, token = _get_auth_context()
authorized: list[Tool] = []
@@ -610,17 +637,18 @@ class FastMCP(
# Apply session transforms to single item
tools = await apply_session_transforms([tool])
- if tools and is_enabled(tools[0]):
+ if tools and is_enabled(tools[0]) and _is_model_visible(tools[0]):
return tools[0]
- # The highest version is disabled. If an explicit version was requested,
- # respect the disable. Otherwise fall back to the next-highest enabled version.
+ # The highest version is disabled (or app-only). If an explicit version
+ # was requested, respect that. Otherwise fall back to the next-highest
+ # enabled, model-visible version.
if version is not None:
return None
all_tools = [t for t in await super().list_tools() if t.name == name]
all_tools = list(await apply_session_transforms(all_tools))
- enabled = [t for t in all_tools if is_enabled(t)]
+ enabled = [t for t in all_tools if is_enabled(t) and _is_model_visible(t)]
skip_auth, token = _get_auth_context()
authorized: list[Tool] = []
diff --git a/tests/test_apps.py b/tests/test_apps.py
index 5d41897a2..70e5089c5 100644
--- a/tests/test_apps.py
+++ b/tests/test_apps.py
@@ -225,10 +225,16 @@ class TestToolRegistrationWithApp:
def my_tool() -> str:
return "hello"
+ # App-only tools (visibility=["app"]) are hidden from list_tools
tools = list(await server.list_tools())
- assert tools[0].meta is not None
- assert tools[0].meta["ui"]["resourceUri"] == "ui://foo"
- assert tools[0].meta["ui"]["visibility"] == ["app"]
+ assert len(tools) == 0
+
+ # But the tool exists on the provider
+ tool = await server._get_tool("my_tool")
+ assert tool is not None
+ assert tool.meta is not None
+ assert tool.meta["ui"]["resourceUri"] == "ui://foo"
+ assert tool.meta["ui"]["visibility"] == ["app"]
async def test_app_merges_with_existing_meta(self):
server = FastMCP("test")
@@ -250,8 +256,10 @@ class TestToolRegistrationWithApp:
def my_tool() -> str:
return "hello"
- tools = list(await server.list_tools())
- mcp_tool = tools[0].to_mcp_tool()
+ # App-only tools are hidden from list_tools, verify via provider
+ tool = await server._get_tool("my_tool")
+ assert tool is not None
+ mcp_tool = tool.to_mcp_tool()
assert mcp_tool.meta is not None
assert mcp_tool.meta["ui"]["resourceUri"] == "ui://app"
assert mcp_tool.meta["ui"]["visibility"] == ["app"]
@@ -414,7 +422,9 @@ class TestIntegration:
server = FastMCP("test")
@server.tool(
- app=AppConfig(resource_uri="ui://app/view.html", visibility=["app"])
+ app=AppConfig(
+ resource_uri="ui://app/view.html", visibility=["app", "model"]
+ )
)
async def my_tool() -> dict[str, str]:
return {"result": "ok"}
@@ -422,11 +432,10 @@ class TestIntegration:
async with Client(server) as client:
tools = await client.list_tools()
assert len(tools) == 1
- # _meta.ui is preserved — the host decides what to do with it
meta = tools[0].meta
assert meta is not None
assert meta["ui"]["resourceUri"] == "ui://app/view.html"
- assert meta["ui"]["visibility"] == ["app"]
+ assert meta["ui"]["visibility"] == ["app", "model"]
async def test_resource_with_ui_scheme_roundtrip(self):
server = FastMCP("test")
@@ -470,7 +479,9 @@ class TestIntegration:
"""Server advertises extension AND tool has app meta."""
server = FastMCP("test")
- @server.tool(app=AppConfig(resource_uri="ui://dashboard", visibility=["app"]))
+ @server.tool(
+ app=AppConfig(resource_uri="ui://dashboard", visibility=["app", "model"])
+ )
def dashboard() -> str:
return "data"
diff --git a/tests/test_fastmcp_app.py b/tests/test_fastmcp_app.py
index 6fdeb2aed..5db403e49 100644
--- a/tests/test_fastmcp_app.py
+++ b/tests/test_fastmcp_app.py
@@ -15,6 +15,7 @@ from unittest.mock import AsyncMock
import pytest
from prefab_ui.app import ResolvedTool
+from prefab_ui.components import Text
from fastmcp import Client, FastMCP
from fastmcp.server.app import (
@@ -460,11 +461,11 @@ class TestCallToolAppRouting:
result = await server.call_tool("save", {"name": "alice"}, app_name="contacts")
assert result.content[0].text == "saved alice" # type: ignore[union-attr]
- async def test_call_tool_without_app_name(self):
- """Regular name-based resolution still works."""
+ async def test_call_tool_without_app_name_model_visible(self):
+ """Regular name-based resolution works for model-visible tools."""
app = FastMCPApp("test")
- @app.tool()
+ @app.tool(model=True)
def save(name: str) -> str:
return f"saved {name}"
@@ -569,6 +570,107 @@ class TestCallToolAppRouting:
assert result.content[0].text == "found" # type: ignore[union-attr]
+# ---------------------------------------------------------------------------
+# App-only tool filtering from server list_tools / get_tool
+# ---------------------------------------------------------------------------
+
+
+class TestAppOnlyToolFiltering:
+ async def test_app_only_tool_hidden_from_list_tools(self):
+ """@app.tool() (visibility=["app"]) should not appear in server.list_tools()."""
+ app = FastMCPApp("crm")
+
+ @app.tool()
+ def save_contact(name: str) -> str:
+ return name
+
+ server = FastMCP("Platform")
+ server.add_provider(app)
+
+ tools = await server.list_tools()
+ names = [t.name for t in tools]
+ assert "save_contact" not in names
+
+ async def test_model_visible_tool_in_list_tools(self):
+ """@app.tool(model=True) (visibility=["app","model"]) appears in list_tools."""
+ app = FastMCPApp("crm")
+
+ @app.tool(model=True)
+ def query(search: str) -> list[str]:
+ return [search]
+
+ server = FastMCP("Platform")
+ server.add_provider(app)
+
+ tools = await server.list_tools()
+ names = [t.name for t in tools]
+ assert "query" in names
+
+ async def test_ui_tool_in_list_tools(self):
+ """@app.ui() (visibility=["model"]) appears in list_tools."""
+ app = FastMCPApp("dashboard")
+
+ @app.ui()
+ def show_dashboard() -> str:
+ return "dashboard"
+
+ server = FastMCP("Platform")
+ server.add_provider(app)
+
+ tools = await server.list_tools()
+ names = [t.name for t in tools]
+ assert "show_dashboard" in names
+
+ async def test_app_only_tool_still_callable_via_app_name(self):
+ """Even though filtered from list_tools, app-only tools are callable via call_tool with app_name."""
+ app = FastMCPApp("contacts")
+
+ @app.tool()
+ def save(name: str) -> str:
+ return f"saved {name}"
+
+ server = FastMCP("Platform")
+ server.add_provider(app)
+
+ # Verify it's hidden from list_tools
+ tools = await server.list_tools()
+ names = [t.name for t in tools]
+ assert "save" not in names
+
+ # But still callable via app_name routing
+ result = await server.call_tool("save", {"name": "alice"}, app_name="contacts")
+ assert result.content[0].text == "saved alice" # type: ignore[union-attr]
+
+ async def test_app_only_tool_hidden_from_get_tool(self):
+ """server.get_tool() returns None for app-only tools."""
+ app = FastMCPApp("crm")
+
+ @app.tool()
+ def save_contact(name: str) -> str:
+ return name
+
+ server = FastMCP("Platform")
+ server.add_provider(app)
+
+ tool = await server.get_tool("save_contact")
+ assert tool is None
+
+ async def test_app_only_tool_hidden_with_namespace(self):
+ """App-only tools hidden even when accessed through a namespace."""
+ app = FastMCPApp("crm")
+
+ @app.tool()
+ def save(name: str) -> str:
+ return name
+
+ server = FastMCP("Platform")
+ server.add_provider(app, namespace="crm")
+
+ tools = await server.list_tools()
+ names = [t.name for t in tools]
+ assert "crm_save" not in names
+
+
# ---------------------------------------------------------------------------
# End-to-end via Client
# ---------------------------------------------------------------------------
@@ -721,3 +823,53 @@ class TestComposition:
resources = await app._list_resources()
uris = [str(r.uri) for r in resources]
assert any("ui://prefab/renderer.html" in uri for uri in uris)
+
+
+# ---------------------------------------------------------------------------
+# Integration: full end-to-end with client, namespacing, and structured content
+# ---------------------------------------------------------------------------
+
+
+class TestAppIntegration:
+ async def test_full_app_lifecycle_through_client(self):
+ """End-to-end: mount an app on a namespaced server, call UI tool
+ through a client (verifying structured_content contains _meta.fastmcp.app),
+ then call the backend tool via server.call_tool with app_name."""
+ app = FastMCPApp("contacts")
+
+ @app.ui()
+ def contact_form() -> Text:
+ return Text(content="Enter contact details")
+
+ @app.tool()
+ def save_contact(name: str, email: str) -> dict[str, str]:
+ return {"name": name, "email": email}
+
+ server = FastMCP("Platform")
+ server.add_provider(app, namespace="crm")
+
+ # The @app.ui() tool should be visible (namespaced) to the client.
+ # The @app.tool() backend tool should NOT appear.
+ async with Client(server) as client:
+ tools = await client.list_tools()
+ tool_names = [t.name for t in tools]
+ assert "crm_contact_form" in tool_names
+ assert "crm_save_contact" not in tool_names
+
+ # Call the UI tool through the client and check structured_content
+ result = await client.call_tool_mcp("crm_contact_form", {})
+ sc = result.structuredContent
+ assert sc is not None
+ assert "_meta" in sc
+ assert sc["_meta"]["fastmcp"]["app"] == "contacts"
+
+ # Call the backend tool via server.call_tool with app_name
+ # (bypasses namespace transforms and visibility filtering)
+ backend_result = await server.call_tool(
+ "save_contact",
+ {"name": "Alice", "email": "alice@example.com"},
+ app_name="contacts",
+ )
+ result_text = backend_result.content[0].text # type: ignore[union-attr]
+ assert "Alice" in result_text
+ assert "alice@example.com" in result_text