Skip to main content
Prefab integrates with FastMCP to build MCP Apps — interactive UIs that render directly inside the conversation. Mark any tool with app=True and return a component tree or a PrefabApp:
MCP App support requires a development branch of FastMCP that has not been released yet. This page will be updated with install instructions when it ships.
from fastmcp import FastMCP
from prefab_ui.app import PrefabApp
from prefab_ui.components import Column, Heading, Text
from prefab_ui.components.control_flow import ForEach
mcp = FastMCP("My Server")

ITEMS = [{"name": "Widget"}, {"name": "Gadget"}, {"name": "Gizmo"}]

@mcp.tool(app=True)
def browse() -> PrefabApp:
    """Show all items."""
    with Column(gap=4) as view:
        Heading("Items")
        with ForEach("items"):
            Text("{{ name }}")
    return PrefabApp(view=view, state={"items": ITEMS})
When a host like Claude Desktop calls this tool, the user sees a fully interactive UI instead of plain text. FastMCP handles the wiring automatically: it registers a shared Prefab renderer as a ui:// resource, sets the MCP Apps metadata on your tool, and converts PrefabApp returns to structuredContent in the tool result.

How It Works

The flow has three steps:
  1. Tool call — the host calls your tool via MCP
  2. PrefabApp → structuredContent — FastMCP serializes your return value into the Prefab JSON envelope (version, view, state, defs)
  3. Renderer — the host loads the Prefab renderer (from ui://prefab/renderer.html) and passes it the structured content. The renderer builds the UI.
When the user interacts with the UI (clicks a button, submits a form), CallTool sends a new tool call through MCP back to your server. The tool returns a fresh PrefabApp, and the renderer updates.

Returning UI

You can return either a PrefabApp or a bare component. Returning a component is a shorthand — FastMCP wraps it in a PrefabApp automatically.
from prefab_ui.rx import Rx

name = Rx("name")

@mcp.tool(app=True)
def dashboard() -> PrefabApp:
    with Column(gap=4) as view:
        Heading("Dashboard")
        Text(f"Welcome, {name}")
    return PrefabApp(
        title="Dashboard",
        view=view,
        state={"name": "Alice"},
    )
Return a PrefabApp when you need initial state, a page title, or reusable definitions. Return a bare component for simple, stateless views.

Patterns

Calling Back to the Server

CallTool is the MCP equivalent of Fetch. It sends a tool call through MCP and writes the response into client-side state:
@mcp.tool(app=True)
def browse() -> PrefabApp:
    with Column(gap=4) as view:
        Input(
            name="q",
            placeholder="Search...",
            on_change=[
                SetState("q", "{{ $event }}"),
                CallTool("search", arguments={"q": "{{ $event }}"}, result_key="results"),
            ],
        )
        Slot("results")
    return PrefabApp(view=view, state={"q": "", "results": None})

@mcp.tool
def search(q: str = "") -> PrefabApp:
    matches = [i for i in ITEMS if q.lower() in i["name"].lower()] if q else ITEMS
    with ForEach("items") as view:
        Text("{{ name }}")
    return PrefabApp(view=view, state={"items": matches})
The first tool (browse) defines the layout with a Slot. The second tool (search) returns a component tree that fills that slot. This is the core MCP Apps pattern: the initial tool sets up the shell, and subsequent tool calls swap content in and out.
search doesn’t need app=True — it’s a helper tool called from within the UI, not an entry point that a host would show directly. FastMCP still auto-wires it because its return type is PrefabApp.

Dynamic Component Results with Slot

When a CallTool has result_key, the tool’s PrefabApp response is written into that state key. A Slot watching that key renders whatever component tree arrives:
# In the main view
with Slot("detail"):
    Text("Select an item", css_class="text-muted-foreground")

# A tool that returns component content for the slot
@mcp.tool
def get_detail(id: str) -> PrefabApp:
    item = lookup(id)
    with Card() as view:
        CardTitle(item["name"])
        Text(item["description"])
    return PrefabApp(view=view, state={"item": item})
The Slot shows its fallback children until a CallTool populates the state key. This is the same pattern as Dynamic Component Routes in the API Server guide — the server decides what to render, not just what data to return.

Error Handling

If a tool raises an exception, the MCP protocol surfaces it as an error. Use on_error to display it:
from prefab_ui.rx import Rx

new_title = Rx("new_title")

CallTool(
    "add_entry",
    arguments={"title": new_title},
    on_error=ShowToast("{{ $error }}", variant="error"),
)
On the server side, just raise:
@mcp.tool
def add_entry(title: str) -> PrefabApp:
    if not title.strip():
        raise ValueError("Title is required")
    # ...

Communicating with the Host

Beyond CallTool, MCP Apps can interact with the host conversation using SendMessage and UpdateContext. SendMessage sends a message to the conversation as if the user typed it — useful for quick-action buttons that trigger follow-up questions:
from prefab_ui.actions.mcp import SendMessage

Button("Explain this",
       on_click=SendMessage("Explain {{ title }} in more detail."))
UpdateContext silently updates what the model knows without creating a visible message. The context is attached to the next conversation turn, so the model can reference it without the user needing to re-explain:
from prefab_ui.actions import ShowToast
from prefab_ui.actions.mcp import UpdateContext

Button(
    "Send to Chat",
    on_click=[
        UpdateContext(
            content="Selected item: {{ name }} ({{ category }})"
        ),
        ShowToast("Added to context", variant="success"),
    ],
)
UpdateContext depends on host support. The context is delivered to the host, but whether the model sees it depends on the host’s implementation of the MCP Apps ui/update-model-context method.

Tool Visibility

By default, all tools registered on your server are visible to the model — including helper tools meant only for UI interactions. When the model can see tools like search or delete_item, it may call them directly instead of letting the user interact through the UI. Use AppConfig(visibility=["app"]) to mark tools as app-only. These tools remain callable via CallTool from the UI, but the host should exclude them when presenting tools to the model:
from fastmcp.server.apps import AppConfig

@mcp.tool(app=True)
def browse() -> PrefabApp:
    """The entry point — visible to the model."""
    ...

@mcp.tool(app=AppConfig(visibility=["app"]))
def search(q: str = "") -> PrefabApp:
    """Called from the UI only — hidden from the model."""
    ...
The entry point tool uses app=True so the model can invoke it. Helper tools use AppConfig(visibility=["app"]) so they’re accessible to CallTool but won’t appear in the model’s tool list.
Tool visibility is metadata that the host is responsible for enforcing. The server returns all tools from tools/list regardless of visibility — the host filters based on the visibility field in the tool’s app metadata.

API Server vs FastMCP

Both use the same components and state model. The difference is transport:
FastMCPAPI Server
TransportMCP protocolHTTP (fetch)
Server actionCallToolFetch
Host actionsSendMessage, UpdateContext
HostingInside Claude Desktop, ChatGPT, etc.Standalone web page
RendererProvided by the MCP hostBundled in PrefabApp.html()
If you’re building an MCP server, use CallTool. If you’re building a web app, use Fetch. SendMessage and UpdateContext are MCP-only — they communicate with the host’s conversation, which doesn’t exist in standalone mode. The component tree and client-side actions are identical either way.

Example App

The examples/hitchhikers-guide directory contains a complete working MCP server — a Hitchhiker’s Guide catalog with search, dialog-based entry creation, inline deletion, and error handling. The same directory also contains a FastAPI version of the same app for comparison.
uv run examples/hitchhikers-guide/mcp_server.py