Back to Fastmcp

FastMCPApp

docs/apps/fastmcp-app.mdx

4.0.0b114.6 KB
Original Source

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 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, and hosts act on those declarations to decide what the model sees.

Composition is handled by never writing the name down. CallTool takes a function reference, and FastMCP resolves it when the UI is serialized — to whatever that tool is actually called by then. Mount the server under a namespace and the button calls notes_add_note; put a gateway in front and it calls whatever the gateway lists. Since you never wrote a name, renaming cannot break it. The architecture page covers how that resolution works.

The one rule that comes with this: an app name must be unique within a server. Composing the same app twice breaks its UI — two copies of FastMCPApp("notes") are indistinguishable no matter what namespaces you mount them under, so FastMCP declines to bind rather than picking one. Name apps for what they serve: FastMCPApp("notes-acme") and FastMCPApp("notes-globex"). The architecture page explains why identity works this way.

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": "[email protected]"})

# 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
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.

Next steps