> ## Documentation Index

> Fetch the complete documentation index at: /llms.txt

> Use this file to discover all available pages before exploring further.

Skip to main content
Apps

# FastMCPApp

Wire an interactive UI to backend tools with managed visibility and composition safety.

New in version `3.2.0`
Prefab is under active development with frequent breaking changes. FastMCP sets a minimum `prefab-ui` version but does not pin an upper bound — pin `prefab-ui` to a specific version in your own dependencies before deploying.
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.

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

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

    @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 → backend

`CallTool` 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": "alice@example.com"})

    # 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})

###
​
Handling results

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` shorthand

When 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))

##
​
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:

    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"),
        ],
    )

###
​
Loading states

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

Forms collect input and submit it to a tool. When submitted, named input values become the tool’s arguments.

###
​
Manual forms

    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:

    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:

    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`:

    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])

###
​
Running standalone

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()

##
​
A full example: contact manager

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": "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 — the building blocks: charts, tables, dashboards, reactive state
- Examples — complete working servers
- Development — preview and test app tools locally
- Prefab UI docs — full component reference

Quickstart
Previous
Interactive Tools
Next
Ctrl+I
Assistant
Responses are generated using AI and may contain mistakes.
\n\n
\n
\n \n
\n
","contacts":" \n
\n\n Prefab\n \n \n \n \n\n
\n
\n \n
\n
","dashboard":" \n
\n\n Prefab\n \n \n \n \n\n
\n
\n \n
\n
","data-table":" \n
\n\n Prefab\n \n \n \n \n\n
\n
\n \n
\n
","hitchhikers":" \n
\n\n Prefab Showcase\n \n \n \n \n\n
\n
\n \n
\n
","pie-chart":" \n
\n\n Prefab\n \n \n \n \n\n
\n
\n \n
\n
","reactive":" \n
\n\n Prefab\n \n \n \n \n\n
\n
\n \n
\n
","team-directory-reactive":" \n
\n\n Prefab\n \n \n \n \n\n
\n
\n \n
\n
","team-directory":" \n
\n\n Prefab\n \n \n \n \n\n
\n
\n \n
\n
"};