60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
471 lines
14 KiB
Plaintext
471 lines
14 KiB
Plaintext
---
|
|
title: FastMCPApp
|
|
sidebarTitle: FastMCPApp
|
|
description: Wire an interactive UI to backend tools with managed visibility and composition safety.
|
|
icon: puzzle-piece
|
|
tag: NEW
|
|
---
|
|
|
|
import { VersionBadge } from '/snippets/version-badge.mdx'
|
|
import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx'
|
|
import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx'
|
|
|
|
<VersionBadge version="3.2.0" />
|
|
|
|
<PrefabPinWarning />
|
|
|
|
<PrefabDemoFrame demo="contacts" height="650px" title="Contacts app demo" />
|
|
|
|
Search a list, fill out a form, click save, the list updates. That pattern — UI that reads and writes data on the server — needs two things: backend tools that actually do the work, and a way to call them from the UI. `FastMCPApp` handles the wiring.
|
|
|
|
You'll build up to the contacts app above by the end of this page. Let's start with something smaller.
|
|
|
|
## A minimal interactive app
|
|
|
|
The smallest interactive app: a form that saves a note, and a list that updates when the user submits.
|
|
|
|
```python
|
|
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, Column, ForEach, Form, Heading,
|
|
Input, Row, Separator, Text,
|
|
)
|
|
from prefab_ui.rx import RESULT
|
|
from fastmcp import FastMCP, FastMCPApp
|
|
|
|
app = FastMCPApp("Notes")
|
|
notes_db: list[dict] = []
|
|
|
|
|
|
@app.tool()
|
|
def add_note(title: str, body: str) -> list[dict]:
|
|
"""Save a note and return all notes."""
|
|
notes_db.append({"title": title, "body": body})
|
|
return list(notes_db)
|
|
|
|
|
|
@app.ui()
|
|
def notes_app() -> PrefabApp:
|
|
"""Open the notes app."""
|
|
with Column(gap=6, css_class="p-6") as view:
|
|
Heading("Notes")
|
|
|
|
with ForEach("notes") as note:
|
|
with Row(gap=2, align="center"):
|
|
Text(note.title, css_class="font-semibold")
|
|
Badge(note.body)
|
|
|
|
Separator()
|
|
|
|
with Form(
|
|
on_submit=CallTool(
|
|
"add_note",
|
|
on_success=[
|
|
SetState("notes", RESULT),
|
|
ShowToast("Note saved!", variant="success"),
|
|
],
|
|
on_error=ShowToast("Failed to save", variant="error"),
|
|
)
|
|
):
|
|
Input(name="title", label="Title", required=True)
|
|
Input(name="body", label="Body", required=True)
|
|
Button("Add Note")
|
|
|
|
return PrefabApp(view=view, state={"notes": list(notes_db)})
|
|
|
|
|
|
mcp = FastMCP("Notes Server", providers=[app])
|
|
```
|
|
|
|
The model sees one tool: `notes_app`. Calling it opens the UI. When the user submits the form, `CallTool("add_note")` fires, the server saves the note, returns the updated list, and `SetState("notes", RESULT)` writes that list back into state. `ForEach("notes")` re-renders. The model never sees `add_note` — it's UI-only.
|
|
|
|
## Why not just `@mcp.tool(app=True)`?
|
|
|
|
A fair question. Any [Interactive Tool](/apps/prefab) can call a server tool — there's nothing stopping you from putting `CallTool("add_note")` inside a regular `@mcp.tool(app=True)`. It works for one or two tools. Things get harder once the app grows:
|
|
|
|
- Which tools should the model see, and which are UI-only?
|
|
- What happens to `CallTool("add_note")` when you mount this server under a namespace and the tool becomes `notes_add_note`?
|
|
- How do you keep it all wired correctly as you compose servers?
|
|
|
|
`FastMCPApp` owns these concerns. Entry points register as model-visible. Backend tools register as UI-only by default. Backend tools get globally stable identifiers that survive namespacing, and `CallTool` accepts function references, so references stay valid when you compose servers.
|
|
|
|
The rest of this page covers each piece in turn.
|
|
|
|
## `@app.ui()` — entry points
|
|
|
|
Entry points are what the model sees. They return a `PrefabApp` and default to `visibility=["model"]`, showing up in the LLM tool list but not callable from within the UI.
|
|
|
|
```python
|
|
@app.ui()
|
|
def dashboard() -> PrefabApp:
|
|
"""The model calls this to open the dashboard."""
|
|
with Column(gap=4, css_class="p-6") as view:
|
|
Heading("Dashboard")
|
|
...
|
|
return PrefabApp(view=view)
|
|
```
|
|
|
|
`@app.ui()` supports the same options as `@mcp.tool`: `name`, `description`, `title`, `tags`, `icons`, `auth`, and `timeout`.
|
|
|
|
## `@app.tool()` — backend tools
|
|
|
|
Backend tools do the work. By default they're visible only to the UI (`visibility=["app"]`), not the model.
|
|
|
|
```python
|
|
@app.tool()
|
|
def save_contact(name: str, email: str) -> list[dict]:
|
|
"""Save a contact and return the updated list."""
|
|
db.append({"name": name, "email": email})
|
|
return list(db)
|
|
```
|
|
|
|
If you want a tool callable by both the model and the UI, pass `model=True`:
|
|
|
|
```python
|
|
@app.tool(model=True)
|
|
def list_contacts() -> list[dict]:
|
|
"""Both the model and the UI can call this."""
|
|
return list(db)
|
|
```
|
|
|
|
Backend tools support `name`, `description`, `auth`, and `timeout`.
|
|
|
|
## `CallTool` — UI → backend
|
|
|
|
`CallTool` is how the UI invokes a backend tool. Pass the tool's name (or a direct function reference):
|
|
|
|
```python
|
|
from prefab_ui.actions.mcp import CallTool
|
|
|
|
CallTool("save_contact", arguments={"name": "Alice", "email": "alice@example.com"})
|
|
|
|
# Or a function reference — resolves to a stable global key
|
|
CallTool(save_contact, arguments={...})
|
|
```
|
|
|
|
Arguments can reference state with `Rx`:
|
|
|
|
```python
|
|
from prefab_ui.rx import STATE
|
|
|
|
CallTool("search", arguments={"query": STATE.search_term})
|
|
```
|
|
|
|
### Handling results
|
|
|
|
Server calls are async. Use `on_success` and `on_error` callbacks:
|
|
|
|
```python
|
|
from prefab_ui.actions import SetState, ShowToast
|
|
from prefab_ui.rx import RESULT
|
|
|
|
CallTool(
|
|
"save_contact",
|
|
on_success=[
|
|
SetState("contacts", RESULT),
|
|
ShowToast("Saved!", variant="success"),
|
|
],
|
|
on_error=ShowToast("Something went wrong", variant="error"),
|
|
)
|
|
```
|
|
|
|
`RESULT` is a reactive reference to the tool's return value, available inside `on_success`. `ERROR` (from `prefab_ui.rx`) is the counterpart inside `on_error`. Callbacks can be a single action or a list; they execute in order and short-circuit on error.
|
|
|
|
### `result_key` shorthand
|
|
|
|
When a tool's return value should replace a state key, use `result_key`:
|
|
|
|
```python
|
|
CallTool("list_contacts", result_key="contacts")
|
|
|
|
# same as:
|
|
CallTool("list_contacts", on_success=SetState("contacts", RESULT))
|
|
```
|
|
|
|
## Actions
|
|
|
|
`CallTool` is one of several actions. Actions attach to handlers like `on_click`, `on_submit`, and `on_change`.
|
|
|
|
Client-side actions run instantly in the browser, no server round-trip:
|
|
|
|
```python
|
|
from prefab_ui.actions import SetState, ToggleState, AppendState, PopState, ShowToast
|
|
|
|
SetState("count", 42)
|
|
ToggleState("expanded")
|
|
AppendState("items", {"name": "New Item"})
|
|
PopState("items", 0)
|
|
ShowToast("Done!", variant="success")
|
|
```
|
|
|
|
Pass a list to chain actions:
|
|
|
|
```python
|
|
Button(
|
|
"Reset",
|
|
on_click=[
|
|
SetState("query", ""),
|
|
SetState("results", []),
|
|
ShowToast("Cleared"),
|
|
],
|
|
)
|
|
```
|
|
|
|
### Loading states
|
|
|
|
A common pattern: disable a button and show a spinner while a call is in flight.
|
|
|
|
```python
|
|
from prefab_ui.rx import Rx
|
|
|
|
saving = Rx("saving")
|
|
|
|
Button(
|
|
saving.then("Saving...", "Save"),
|
|
disabled=saving,
|
|
on_click=[
|
|
SetState("saving", True),
|
|
CallTool(
|
|
"save_data",
|
|
on_success=[
|
|
SetState("saving", False),
|
|
SetState("result", RESULT),
|
|
ShowToast("Saved!", variant="success"),
|
|
],
|
|
on_error=[
|
|
SetState("saving", False),
|
|
ShowToast("Failed", variant="error"),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
|
|
# PrefabApp(view=view, state={"saving": False, ...})
|
|
```
|
|
|
|
## Forms
|
|
|
|
Forms collect input and submit it to a tool. When submitted, named input values become the tool's arguments.
|
|
|
|
### Manual forms
|
|
|
|
```python
|
|
from prefab_ui.components import Form, Input, Select, SelectOption, Textarea, Button
|
|
|
|
with Form(
|
|
on_submit=CallTool(
|
|
"create_ticket",
|
|
on_success=ShowToast("Ticket created!", variant="success"),
|
|
)
|
|
):
|
|
Input(name="title", label="Title", required=True)
|
|
with Select(name="priority", label="Priority"):
|
|
SelectOption("Low", value="low")
|
|
SelectOption("Medium", value="medium")
|
|
SelectOption("High", value="high")
|
|
Textarea(name="description", label="Description")
|
|
Button("Create Ticket")
|
|
```
|
|
|
|
On submit, `CallTool` receives `{"title": ..., "priority": ..., "description": ...}`.
|
|
|
|
### Forms from Pydantic models
|
|
|
|
For structured input, `Form.from_model()` generates the whole form — inputs, labels, validation:
|
|
|
|
```python
|
|
from typing import Literal
|
|
from pydantic import BaseModel, Field
|
|
|
|
class BugReport(BaseModel):
|
|
title: str = Field(title="Bug Title")
|
|
severity: Literal["low", "medium", "high", "critical"] = Field(
|
|
title="Severity", default="medium"
|
|
)
|
|
description: str = Field(title="Description")
|
|
|
|
|
|
@app.ui()
|
|
def report_bug() -> PrefabApp:
|
|
with Column(gap=4, css_class="p-6") as view:
|
|
Heading("Report a Bug")
|
|
Form.from_model(
|
|
BugReport,
|
|
on_submit=CallTool(
|
|
"create_bug",
|
|
on_success=ShowToast("Bug filed!", variant="success"),
|
|
),
|
|
)
|
|
return PrefabApp(view=view)
|
|
|
|
|
|
@app.tool()
|
|
def create_bug(data: BugReport) -> str:
|
|
return f"Created: {data.title}"
|
|
```
|
|
|
|
`str` becomes a text input, `Literal` becomes a select, `bool` becomes a checkbox. Field titles and defaults are respected.
|
|
|
|
## Composition and namespacing
|
|
|
|
The reason `FastMCPApp` exists — and why you'd pick it over plain `@mcp.tool(app=True)` with string-based `CallTool` — is composition safety.
|
|
|
|
When you mount a server under a namespace, tool names get prefixed:
|
|
|
|
```python
|
|
platform = FastMCP("Platform")
|
|
platform.mount("contacts", contacts_server)
|
|
|
|
# "save_contact" becomes "contacts_save_contact"
|
|
```
|
|
|
|
`CallTool("save_contact")` would now be broken. But `CallTool(save_contact)` with a function reference resolves to a globally stable identifier that bypasses the namespace. Your app works the same whether standalone or mounted.
|
|
|
|
### Mounting
|
|
|
|
`FastMCPApp` is a Provider. Add it to a server with `providers=` or `add_provider`:
|
|
|
|
```python
|
|
mcp = FastMCP("Platform", providers=[app])
|
|
|
|
# or
|
|
mcp = FastMCP("Platform")
|
|
mcp.add_provider(app)
|
|
```
|
|
|
|
Multiple apps can coexist; each gets its own global keys, so there's no collision even if two apps have a tool named `save`.
|
|
|
|
```python
|
|
mcp = FastMCP("Platform", providers=[contacts_app, inventory_app, billing_app])
|
|
```
|
|
|
|
### Running standalone
|
|
|
|
For development, `FastMCPApp` has a `run()` shortcut that wraps itself in a temporary `FastMCP` server:
|
|
|
|
```python
|
|
app = FastMCPApp("Contacts")
|
|
# ... register tools ...
|
|
|
|
if __name__ == "__main__":
|
|
app.run()
|
|
```
|
|
|
|
## A full example: contact manager
|
|
|
|
This brings everything together — entry point, backend tools, Pydantic form, manual form, state, actions, and multi-visibility.
|
|
|
|
```python expandable
|
|
from __future__ import annotations
|
|
|
|
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, Column, ForEach, Form,
|
|
Heading, Input, Muted, Row, Separator, Text,
|
|
)
|
|
from prefab_ui.rx import RESULT, Rx
|
|
from pydantic import BaseModel, Field
|
|
from fastmcp import FastMCP, FastMCPApp
|
|
|
|
contacts_db: list[dict] = [
|
|
{"name": "Arthur Dent", "email": "arthur@earth.com", "category": "Customer"},
|
|
{"name": "Ford Prefect", "email": "ford@betelgeuse.org", "category": "Partner"},
|
|
]
|
|
|
|
|
|
class ContactModel(BaseModel):
|
|
name: str = Field(title="Full Name", min_length=1)
|
|
email: str = Field(title="Email")
|
|
category: Literal["Customer", "Vendor", "Partner", "Other"] = "Other"
|
|
|
|
|
|
app = FastMCPApp("Contacts")
|
|
|
|
|
|
@app.tool()
|
|
def save_contact(data: ContactModel) -> list[dict]:
|
|
"""Save a new contact and return the updated list."""
|
|
contacts_db.append(data.model_dump())
|
|
return list(contacts_db)
|
|
|
|
|
|
@app.tool()
|
|
def search_contacts(query: str) -> list[dict]:
|
|
"""Filter contacts by name or email."""
|
|
q = query.lower()
|
|
return [
|
|
c for c in contacts_db
|
|
if q in c["name"].lower() or q in c["email"].lower()
|
|
]
|
|
|
|
|
|
@app.tool(model=True)
|
|
def list_contacts() -> list[dict]:
|
|
"""Return all contacts. Visible to both the model and the UI."""
|
|
return list(contacts_db)
|
|
|
|
|
|
@app.ui()
|
|
def contact_manager() -> PrefabApp:
|
|
"""Open the contact manager."""
|
|
with Column(gap=6, css_class="p-6") as view:
|
|
Heading("Contacts")
|
|
|
|
with ForEach("contacts") as contact:
|
|
with Row(gap=2, align="center"):
|
|
Text(contact.name, css_class="font-medium")
|
|
Muted(contact.email)
|
|
Badge(contact.category)
|
|
|
|
Separator()
|
|
|
|
Heading("Add Contact", level=3)
|
|
Form.from_model(
|
|
ContactModel,
|
|
on_submit=CallTool(
|
|
"save_contact",
|
|
on_success=[
|
|
SetState("contacts", RESULT),
|
|
ShowToast("Contact saved!", variant="success"),
|
|
],
|
|
on_error=ShowToast("Failed to save", variant="error"),
|
|
),
|
|
)
|
|
|
|
Separator()
|
|
|
|
Heading("Search", level=3)
|
|
with Form(
|
|
on_submit=CallTool(
|
|
"search_contacts",
|
|
arguments={"query": Rx("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_db)})
|
|
|
|
|
|
mcp = FastMCP("Contacts Server", providers=[app])
|
|
|
|
if __name__ == "__main__":
|
|
mcp.run()
|
|
```
|
|
|
|
Also available as a runnable server at `examples/apps/contacts/contacts_server.py`.
|
|
|
|
## Next steps
|
|
|
|
- **[Interactive Tools](/apps/prefab)** — the building blocks: charts, tables, dashboards, reactive state
|
|
- **[Examples](/apps/examples)** — complete working servers
|
|
- **[Development](/apps/development)** — preview and test app tools locally
|
|
- **[Prefab UI docs](https://prefab.prefect.io)** — full component reference
|