mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
39af26885f
commit
c04ce8972f
14 changed files with 2353 additions and 49 deletions
332
examples/apps/approvals/approvals_server.py
Normal file
332
examples/apps/approvals/approvals_server.py
Normal 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")
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
588
examples/apps/explorer/explorer_server.py
Normal file
588
examples/apps/explorer/explorer_server.py
Normal 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")
|
||||
444
examples/apps/inventory/inventory_server.py
Normal file
444
examples/apps/inventory/inventory_server.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue