mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-18 19:44:19 +02:00
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)
445 lines
13 KiB
Python
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")
|