Skip to main content
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.
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() 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.
If you want a tool callable by both the model and the UI, pass model=True:
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):
Arguments can reference state with Rx:

Handling results

Server calls are async. Use on_success and on_error callbacks:
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:

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:
Pass a list to chain actions:

Loading states

A common pattern: disable a button and show a spinner while a call is in flight.

Forms

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

Manual forms

On submit, CallTool receives {"title": ..., "priority": ..., "description": ...}.

Forms from Pydantic models

For structured input, Form.from_model() generates the whole form — inputs, labels, validation:
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:
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:
Multiple apps can coexist; each gets its own global keys, so there’s no collision even if two apps have a tool named save.

Running standalone

For development, FastMCPApp has a run() shortcut that wraps itself in a temporary FastMCP server:

A full example: contact manager

This brings everything together — entry point, backend tools, Pydantic form, manual form, state, actions, and multi-visibility.
Also available as a runnable server at examples/apps/contacts/contacts_server.py.

Next steps