From 27abe3c3f0cc2ce1925cc3cbc7968d5637ebc82b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:00:15 -0400 Subject: [PATCH] Add sales dashboard and live system monitor examples, bump prefab-ui to 0.17 (#3696) --- .../sales_dashboard/sales_dashboard_server.py | 233 ++++++++++++++++++ .../system_monitor/system_monitor_server.py | 195 +++++++++++++++ pyproject.toml | 2 +- src/fastmcp/server/server.py | 14 ++ uv.lock | 26 +- 5 files changed, 456 insertions(+), 14 deletions(-) create mode 100644 examples/apps/sales_dashboard/sales_dashboard_server.py create mode 100644 examples/apps/system_monitor/system_monitor_server.py diff --git a/examples/apps/sales_dashboard/sales_dashboard_server.py b/examples/apps/sales_dashboard/sales_dashboard_server.py new file mode 100644 index 000000000..fe38c64bc --- /dev/null +++ b/examples/apps/sales_dashboard/sales_dashboard_server.py @@ -0,0 +1,233 @@ +from prefab_ui.components import ( + Card, + CardContent, + Column, + Grid, + Heading, + Metric, + Muted, + Row, + Separator, + Text, +) +from prefab_ui.components.charts import AreaChart, ChartSeries, PieChart +from prefab_ui.components.data_table import DataTable, DataTableColumn + +from fastmcp import FastMCP + +mcp = FastMCP("Sales Dashboard") + +MONTHLY_REVENUE = [ + {"month": "Jul", "new_business": 182_000, "expansion": 74_000, "renewal": 210_000}, + {"month": "Aug", "new_business": 195_000, "expansion": 81_000, "renewal": 215_000}, + {"month": "Sep", "new_business": 224_000, "expansion": 93_000, "renewal": 208_000}, + {"month": "Oct", "new_business": 210_000, "expansion": 88_000, "renewal": 222_000}, + {"month": "Nov", "new_business": 248_000, "expansion": 102_000, "renewal": 230_000}, + {"month": "Dec", "new_business": 271_000, "expansion": 115_000, "renewal": 238_000}, + {"month": "Jan", "new_business": 235_000, "expansion": 97_000, "renewal": 241_000}, + {"month": "Feb", "new_business": 262_000, "expansion": 108_000, "renewal": 245_000}, + {"month": "Mar", "new_business": 289_000, "expansion": 121_000, "renewal": 252_000}, + {"month": "Apr", "new_business": 305_000, "expansion": 134_000, "renewal": 258_000}, + {"month": "May", "new_business": 318_000, "expansion": 142_000, "renewal": 263_000}, + {"month": "Jun", "new_business": 342_000, "expansion": 156_000, "renewal": 270_000}, +] + +REVENUE_BY_SEGMENT = [ + {"segment": "Enterprise", "revenue": 3_840_000}, + {"segment": "Mid-Market", "revenue": 2_160_000}, + {"segment": "SMB", "revenue": 1_440_000}, + {"segment": "Startup", "revenue": 720_000}, +] + +RECENT_DEALS = [ + { + "company": "Meridian Health Systems", + "amount": "$485,000", + "stage": "Closed Won", + "rep": "Sarah Chen", + "close_date": "Jun 12, 2026", + }, + { + "company": "Atlas Financial Group", + "amount": "$372,000", + "stage": "Closed Won", + "rep": "Marcus Rivera", + "close_date": "Jun 10, 2026", + }, + { + "company": "Pinnacle Manufacturing", + "amount": "$298,000", + "stage": "Negotiation", + "rep": "Aisha Patel", + "close_date": "Jun 28, 2026", + }, + { + "company": "Crestview Logistics", + "amount": "$264,000", + "stage": "Proposal Sent", + "rep": "James O'Brien", + "close_date": "Jul 5, 2026", + }, + { + "company": "Northstar Retail", + "amount": "$215,000", + "stage": "Closed Won", + "rep": "Sarah Chen", + "close_date": "Jun 8, 2026", + }, + { + "company": "Ironclad Security", + "amount": "$189,000", + "stage": "Negotiation", + "rep": "Lena Kowalski", + "close_date": "Jul 1, 2026", + }, + { + "company": "Summit Analytics", + "amount": "$176,000", + "stage": "Closed Won", + "rep": "Marcus Rivera", + "close_date": "Jun 5, 2026", + }, + { + "company": "Brightpath Education", + "amount": "$142,000", + "stage": "Proposal Sent", + "rep": "Aisha Patel", + "close_date": "Jul 12, 2026", + }, + { + "company": "Vantage Media", + "amount": "$128,000", + "stage": "Closed Won", + "rep": "Lena Kowalski", + "close_date": "Jun 3, 2026", + }, + { + "company": "Redwood Hospitality", + "amount": "$97,000", + "stage": "Discovery", + "rep": "James O'Brien", + "close_date": "Jul 20, 2026", + }, +] + + +@mcp.tool(app=True) +def sales_dashboard() -> Column: + """Company sales dashboard with KPIs, revenue trends, segment breakdown, and recent deals.""" + total_revenue = sum( + row["new_business"] + row["expansion"] + row["renewal"] + for row in MONTHLY_REVENUE + ) + current_quarter = sum( + row["new_business"] + row["expansion"] + row["renewal"] + for row in MONTHLY_REVENUE[-3:] + ) + prior_quarter = sum( + row["new_business"] + row["expansion"] + row["renewal"] + for row in MONTHLY_REVENUE[-6:-3] + ) + growth_pct = (current_quarter - prior_quarter) / prior_quarter * 100 + + with Column(gap=6, css_class="p-6") as view: + with Row(gap=2, align="center"): + Heading("Sales Dashboard") + Muted("FY2026 | Last updated Jun 15, 2026") + + with Grid(columns=4, gap=4): + with Card(): + with CardContent(): + Metric( + label="Total Revenue", + value=f"${total_revenue / 1_000_000:.1f}M", + delta="+18.2% YoY", + trend="up", + ) + + with Card(): + with CardContent(): + Metric( + label="Quarterly Growth", + value=f"{growth_pct:.1f}%", + delta="+3.8pp vs prior", + trend="up", + ) + + with Card(): + with CardContent(): + Metric( + label="Active Customers", + value="1,847", + delta="+124 this quarter", + trend="up", + ) + + with Card(): + with CardContent(): + Metric( + label="Avg Deal Size", + value="$236K", + delta="+12% vs H1", + trend="up", + ) + + with Grid(columns=3, gap=6): + with Card(css_class="col-span-2"): + with CardContent(): + Text( + "Monthly Revenue", + css_class="text-sm font-medium text-muted-foreground mb-2", + ) + AreaChart( + data=MONTHLY_REVENUE, + series=[ + ChartSeries(data_key="new_business", label="New Business"), + ChartSeries(data_key="expansion", label="Expansion"), + ChartSeries(data_key="renewal", label="Renewal"), + ], + x_axis="month", + stacked=True, + curve="smooth", + show_legend=True, + height=280, + y_axis_format="compact", + ) + + with Card(): + with CardContent(): + Text( + "Revenue by Segment", + css_class="text-sm font-medium text-muted-foreground mb-2", + ) + PieChart( + data=REVENUE_BY_SEGMENT, + data_key="revenue", + name_key="segment", + show_legend=True, + inner_radius=50, + height=280, + ) + + Separator() + + Text("Recent Deals", css_class="text-lg font-semibold") + + DataTable( + columns=[ + DataTableColumn(key="company", header="Company", sortable=True), + DataTableColumn(key="amount", header="Amount", sortable=True), + DataTableColumn(key="stage", header="Stage", sortable=True), + DataTableColumn(key="rep", header="Sales Rep", sortable=True), + DataTableColumn(key="close_date", header="Close Date", sortable=True), + ], + rows=RECENT_DEALS, + search=True, + paginated=True, + ) + + return view + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/apps/system_monitor/system_monitor_server.py b/examples/apps/system_monitor/system_monitor_server.py new file mode 100644 index 000000000..7105a3697 --- /dev/null +++ b/examples/apps/system_monitor/system_monitor_server.py @@ -0,0 +1,195 @@ +"""System monitor — live CPU, memory, and disk stats from the host machine. + +Auto-refreshes every 3 seconds via SetInterval + CallTool. + +Requires psutil: pip install psutil + +Usage: + fastmcp dev apps system_monitor_server.py +""" + +import platform +import time +from datetime import datetime + +import psutil +from prefab_ui.actions import SetInterval, SetState +from prefab_ui.actions.mcp import CallTool +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Card, + CardContent, + CardHeader, + Column, + Grid, + Heading, + Metric, + Muted, + Progress, + Row, + Select, + SelectOption, + Small, + Text, +) +from prefab_ui.components.charts import AreaChart, ChartSeries +from prefab_ui.components.control_flow import ForEach +from prefab_ui.rx import RESULT, STATE, Rx + +from fastmcp import FastMCP +from fastmcp.apps.app import FastMCPApp + +app = FastMCPApp("Monitor") + +_history: list[dict] = [] + + +def _collect_stats() -> dict: + """Collect a full snapshot of system stats.""" + cpu = psutil.cpu_percent(interval=0.1) + mem = psutil.virtual_memory() + disk = psutil.disk_usage("/") + + now = datetime.now().strftime("%H:%M:%S") + _history.append({"time": now, "cpu": cpu, "memory": mem.percent}) + if len(_history) > 100: + del _history[: len(_history) - 100] + + top_procs = [] + for p in sorted( + psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]), + key=lambda p: p.info.get("cpu_percent") or 0, + reverse=True, + )[:6]: + info = p.info + top_procs.append( + { + "pid": info.get("pid") or 0, + "name": info.get("name") or "unknown", + "cpu": f"{(info.get('cpu_percent') or 0):.1f}%", + "memory": f"{(info.get('memory_percent') or 0):.1f}%", + } + ) + + return { + "cpu": cpu, + "mem_pct": mem.percent, + "mem_used": mem.used // (1024**3), + "mem_total": mem.total // (1024**3), + "disk_pct": disk.percent, + "disk_used": disk.used // (1024**3), + "disk_total": disk.total // (1024**3), + "uptime": _format_uptime(), + "cores": psutil.cpu_count(), + "platform": f"{platform.system()} {platform.machine()}", + "hostname": platform.node(), + "healthy": cpu < 80 and mem.percent < 90, + "history": list(_history), + "top_procs": top_procs, + } + + +def _format_uptime() -> str: + elapsed = int(time.time() - psutil.boot_time()) + days, remainder = divmod(elapsed, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, _ = divmod(remainder, 60) + if days > 0: + return f"{days}d {hours}h {minutes}m" + return f"{hours}h {minutes}m" + + +@app.tool() +def refresh() -> dict: + """Collect fresh system stats.""" + return _collect_stats() + + +@app.ui() +def system_dashboard() -> PrefabApp: + """Live system dashboard with auto-refresh.""" + initial = _collect_stats() + + with PrefabApp(state={"stats": initial, "interval": "500"}) as ui: + with Column( + gap=6, + css_class="p-6", + on_mount=SetInterval( + duration=Rx("interval"), + on_tick=CallTool( + "refresh", + on_success=SetState("stats", RESULT), + ), + ), + ): + with Row(gap=3, align="center"): + Heading("System Monitor") + Badge(STATE.stats.hostname, variant="outline") + with Select(name="interval", css_class="w-32"): + SelectOption("0.5s", value="500") + SelectOption("1s", value="1000") + SelectOption("5s", value="5000") + + with Grid(columns=4, gap=4): + with Card(): + with CardContent(): + Metric(label="CPU", value=f"{STATE.stats.cpu}%") + Progress(value=STATE.stats.cpu) + + with Card(): + with CardContent(): + Metric(label="Memory", value=f"{STATE.stats.mem_pct}%") + Progress(value=STATE.stats.mem_pct) + Muted(f"{STATE.stats.mem_used}GB / {STATE.stats.mem_total}GB") + + with Card(): + with CardContent(): + Metric(label="Disk", value=f"{STATE.stats.disk_pct}%") + Progress(value=STATE.stats.disk_pct) + Muted(f"{STATE.stats.disk_used}GB / {STATE.stats.disk_total}GB") + + with Card(): + with CardContent(): + Metric(label="Uptime", value=STATE.stats.uptime) + Muted(f"{STATE.stats.cores} cores") + + with Grid(columns=[2, 1], gap=4): + with Card(): + with CardHeader(): + Text("CPU & Memory", css_class="text-sm font-medium") + with CardContent(): + AreaChart( + data=STATE.stats.history, + series=[ + ChartSeries(data_key="cpu", label="CPU %"), + ChartSeries(data_key="memory", label="Memory %"), + ], + x_axis="time", + curve="smooth", + show_legend=True, + height=220, + animate=False, + ) + + with Card(): + with CardHeader(): + Text("Top Processes", css_class="text-sm font-medium") + with CardContent(): + with Column(gap=2): + with ForEach("stats.top_procs") as proc: + with Row(justify="between", align="center"): + with Column(gap=0): + Small(proc.name) + Muted(proc.pid) + with Row(gap=2): + Badge(proc.cpu, variant="outline") + Badge(proc.memory, variant="outline") + + return ui + + +mcp = FastMCP("System Monitor", providers=[app]) + +if __name__ == "__main__": + mcp.run() diff --git a/pyproject.toml b/pyproject.toml index f882748b6..4c08bda00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ classifiers = [ [project.optional-dependencies] anthropic = ["anthropic>=0.48.0"] -apps = ["prefab-ui>=0.15.0"] +apps = ["prefab-ui>=0.17.0"] # PyJWT floor: transitive via msal; CVE-2026-32597 affects <= 2.11.0 azure = ["azure-identity>=1.16.0", "PyJWT>=2.12.0"] code-mode = ["pydantic-monty==0.0.8"] diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index bec4f3ad9..789bc42ba 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging import re import secrets import warnings @@ -100,6 +101,19 @@ if TYPE_CHECKING: logger = get_logger(__name__) + +# The MCP SDK warns "Tool X not listed, no validation will be performed" +# for every call to app-only tools (hidden from list_tools by design). +# This fires even when validate_input=False. Suppress it. +class _SuppressUnlistedToolWarning(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return "not listed, no validation" not in record.getMessage() + + +logging.getLogger("mcp.server.lowlevel.server").addFilter( + _SuppressUnlistedToolWarning() +) + F = TypeVar("F", bound=Callable[..., Any]) DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] diff --git a/uv.lock b/uv.lock index 597c33d3a..d7fbac6eb 100644 --- a/uv.lock +++ b/uv.lock @@ -878,7 +878,7 @@ requires-dist = [ { name = "opentelemetry-api", specifier = ">=1.20.0" }, { name = "packaging", specifier = ">=24.0" }, { name = "platformdirs", specifier = ">=4.0.0" }, - { name = "prefab-ui", marker = "extra == 'apps'", specifier = ">=0.15.0" }, + { name = "prefab-ui", marker = "extra == 'apps'", specifier = ">=0.17.0" }, { name = "py-key-value-aio", extras = ["filetree", "keyring", "memory"], specifier = ">=0.4.4,<0.5.0" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.7" }, { name = "pydantic-monty", marker = "extra == 'code-mode'", specifier = "==0.0.8" }, @@ -1811,16 +1811,16 @@ wheels = [ [[package]] name = "prefab-ui" -version = "0.15.0" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cyclopts" }, { name = "pydantic" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/1a/f915a7b2b97392e723f3aaada7438e615cbf119bd01b78f8869dd24dbc33/prefab_ui-0.15.0.tar.gz", hash = "sha256:9fa6ef0b997a1322a7103daf92c340a3badb394ba8ea043249b247f27f65976d", size = 3202919, upload-time = "2026-03-29T00:59:23.185Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/63/e974f66befb851801dbf80e747a787efa59fb7d335ed3fb2e0d0d6f39b0e/prefab_ui-0.17.0.tar.gz", hash = "sha256:c3b2e9706349d013966d317abfa08a82b128f6ecddc8490e8bba73b3a6e3d16c", size = 3993324, upload-time = "2026-03-29T22:35:41.489Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/d8/2a3d73a3efc50e5fb7fcbe4f6516943b9506f930b23362990bed30a921c7/prefab_ui-0.15.0-py3-none-any.whl", hash = "sha256:b8cd787332ab77b808bdc5f21b1305b0bd749c07f4bf1a97dba97302383850da", size = 1044677, upload-time = "2026-03-29T00:59:21.649Z" }, + { url = "https://files.pythonhosted.org/packages/69/36/994783fc52dfaef0123d740cefcbbc2cdd201389b5ae60e3d33110e34042/prefab_ui-0.17.0-py3-none-any.whl", hash = "sha256:9f6a5d8b659acceb2b9da7e3cf1df8a207e5ab87df4f13cd580915443c6816cc", size = 1823254, upload-time = "2026-03-29T22:35:42.953Z" }, ] [[package]] @@ -2235,11 +2235,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2484,11 +2484,11 @@ wheels = [ [[package]] name = "python-json-logger" -version = "4.0.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, ] [[package]] @@ -2851,15 +2851,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.3.3" +version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" }, ] [[package]]