fastmcp/examples/apps/inventory/inventory_server.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

445 lines
13 KiB
Python

"""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 PrefabApp state dict 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
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."""
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,
state={
"search_results": [],
"filtered_items": list(_inventory),
"recent_additions": [],
"selected_category": "All",
"adjust_id": "",
"delete_id": "",
"query": "",
},
)
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
mcp = FastMCP("Inventory Server", providers=[app])
if __name__ == "__main__":
mcp.run(transport="http")