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