Apps Phase 1: docs, examples, app-only tool filtering (#3593)

* Apps Phase 1: docs, examples, app-only tool filtering, Rx() migration

* Clarify architecture page is Prefab-specific

* Fix component reference inaccuracies and DataTable search prop

- Charts import: clarify they must come from prefab_ui.components.charts
- DataTable: searchable→search (the actual prop name), remove nonexistent
  table-level sortable prop
- Select: remove nonexistent options prop, show SelectOption children
- Tabs: default_value→value
- Fix search=True in inventory, patterns, datatable examples

* Consistent Rx usage across all examples, fix imports

* Address review: fix chart imports, Select import, docstring --stdio claims
This commit is contained in:
Jeremiah Lowin 2026-03-24 13:35:29 -04:00 committed by GitHub
commit c04ce8972f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 2353 additions and 49 deletions

View file

@ -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")

View file

@ -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])

View file

@ -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

View file

@ -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")

View file

@ -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")

View file

@ -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)
# ---------------------------------------------------------------------------