docs/apps/interactive-apps.mdx
import { VersionBadge } from '/snippets/version-badge.mdx' import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx'
<VersionBadge version="3.2.0" /> <PrefabPinWarning /> <iframe src="/apps/demos/contacts.html" style={{width:"100%", height:"650px", border:"none", overflow:"hidden", borderRadius:"8px"}} frameBorder="0" scrolling="no"></iframe>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.
The smallest interactive app: a form that saves a note, and a list that updates when the user submits.
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.
@mcp.tool(app=True)?A fair question. Any Interactive Tool 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:
CallTool("add_note") when you mount this server under a namespace and the tool becomes notes_add_note?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 pointsEntry 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.
@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 toolsBackend tools do the work. By default they're visible only to the UI (visibility=["app"]), not the model.
@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:
@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 → backendCallTool is how the UI invokes a backend tool. Pass the tool's name (or a direct function reference):
from prefab_ui.actions.mcp import CallTool
CallTool("save_contact", arguments={"name": "Alice", "email": "[email protected]"})
# Or a function reference — resolves to a stable global key
CallTool(save_contact, arguments={...})
Arguments can reference state with Rx:
from prefab_ui.rx import STATE
CallTool("search", arguments={"query": STATE.search_term})
Server calls are async. Use on_success and on_error callbacks:
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 shorthandWhen a tool's return value should replace a state key, use result_key:
CallTool("list_contacts", result_key="contacts")
# same as:
CallTool("list_contacts", on_success=SetState("contacts", RESULT))
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:
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:
Button(
"Reset",
on_click=[
SetState("query", ""),
SetState("results", []),
ShowToast("Cleared"),
],
)
A common pattern: disable a button and show a spinner while a call is in flight.
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 collect input and submit it to a tool. When submitted, named input values become the tool's arguments.
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": ...}.
For structured input, Form.from_model() generates the whole form — inputs, labels, validation:
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.
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:
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.
FastMCPApp is a Provider. Add it to a server with providers= or add_provider:
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.
mcp = FastMCP("Platform", providers=[contacts_app, inventory_app, billing_app])
For development, FastMCPApp has a run() shortcut that wraps itself in a temporary FastMCP server:
app = FastMCPApp("Contacts")
# ... register tools ...
if __name__ == "__main__":
app.run()
This brings everything together — entry point, backend tools, Pydantic form, manual form, state, actions, and multi-visibility.
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": "[email protected]", "category": "Customer"},
{"name": "Ford Prefect", "email": "[email protected]", "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.