From workshop-skills
Build interactive UIs (charts, tables, forms, dashboards) for FastMCP servers using Prefab UI / MCP Apps. Use when the user wants to add a Prefab component, MCP App, FastMCPApp, `@mcp.tool(app=True)`, or any interactive Prefab-rendered tool to a FastMCP server. Covers project setup, the two app shapes (display vs form-driven), and the specific gotchas that bite first-time builders.
How this skill is triggered — by the user, by Claude, or both
Slash command
/workshop-skills:prefabThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Two shapes only. Pick the smaller one if it fits.
Two shapes only. Pick the smaller one if it fits.
| Shape | When to use | Pattern |
|---|---|---|
| Display tool | one tool call → render charts/tables/dashboards; no UI-driven server calls | @mcp.tool(app=True) returning PrefabApp or ToolResult |
| FastMCPApp | UI needs to call backend tools (forms, search, CRUD, multi-step flows) | FastMCPApp("Name") with @app.ui() entry point + @app.tool() backend tool, registered via providers=[app] |
Concrete patterns for both shapes are in the "Hard rules" and "Component cheat sheet" sections below; for living code, query the mcp__fastmcp__* tools or the FastMCP docs linked at the bottom.
When you scaffold or extend a Prefab demo over a dataset, always include at least one FastMCPApp form that adds a new item to that dataset, even if the user only asked for "list" and "view". The instant a user sees a list, they want to add to it — a display-only app feels broken. This is a default behavior, not a feature request.
Concretely:
list[Pokemon] etc.), not a tuple or frozen structure. The display tools and the form's backend tool both read/write that same module-level list.FastMCPApp (e.g. add_thing_app = FastMCPApp("AddThing")) registered via providers=[add_thing_app] on the main FastMCP.@app.tool()) appends to the list and returns a fresh snapshot for the UI to display in a "Recent" table.arguments={...} with Rx("name") refs, seeded state={...}). Do not invent variants.If the user explicitly says "no form needed" or the dataset is intrinsically read-only (e.g. wrapping a third-party read API), skip this. Otherwise, include the form.
uv add 'fastmcp[apps]'
The [apps] extra pulls in prefab-ui. The leading single-quote matters — your shell otherwise globs [apps]. In pyproject.toml this becomes:
dependencies = [
"fastmcp[apps]>=3.2.4",
]
If you migrate an existing project, edit the dependency line in pyproject.toml and run uv sync.
Pin prefab-ui for production. FastMCP pins a minimum but no upper bound, and Prefab ships breaking changes on every release — a fresh deploy can pull a newer Prefab and break your app.
fastmcp dev apps launches a tool picker + AppBridge host so you can click around the UI without an MCP host:
# Justfile
dev-apps:
uv run fastmcp dev apps src/<your_pkg>/server.py
Opens a local URL in your browser. Auto-reloads on file changes by default. Useful for getting layout right without bouncing through Claude Desktop.
Always use camelCase kwargs on Prefab components. Pydantic accepts snake_case aliases at runtime, but ty only sees the canonical camelCase parameter names. Use cssClass, dataKey, xAxis, inputType, onSubmit, onClick, onSuccess, onError, pageSize, trendSentiment, buttonType, showDots, etc.
NEVER print() to stdout in a stdio MCP server. stdout is the JSON-RPC channel — any stray write corrupts the stream and the host throws "Unexpected token..." JSON parse errors. Use print(..., file=sys.stderr) or a logger if you need debug output.
In FastMCPApp form CallTool, always pass explicit arguments={...} with Rx("name") references AND seed initial values in state={...}. Auto-collect from named inputs is unreliable in current Prefab — explicit binding is the only pattern that consistently sends the form payload to the backend.
Form body should hold Input/Select/Button as direct children. Wrapping each input in Field/FieldTitle/Card breaks Prefab's binding heuristics. If you want labels, use the Label component as a sibling — not a wrapper.
CallTool accepts a function reference, not just a string. With FastMCPApp, prefer CallTool(save_contact, ...) over CallTool("save_contact", ...) — the function reference resolves to a globally stable key that survives namespacing.
Backend @app.tool() functions are hidden from regular MCP clients. They will not appear in client.list_tools(). They're reachable only via CallTool from the UI, or by calling the Python function directly in tests.
Wrap Prefab views in ToolResult when the LLM needs context. PrefabApp alone returns "[Rendered Prefab UI]" as the LLM-visible text. Use ToolResult(content="<text summary for the LLM>", structured_content=view) when the model should reason about what the user is seeing.
| Need | Component | Notes |
|---|---|---|
| toast | ShowToast(text, variant=...) | variants: default | success | error | warning | info. NOT destructive (that's a Button variant). |
| button | Button(label, buttonType=..., variant=...) | buttonType not type; valid variants: default | destructive | outline | secondary | ghost | link | success | warning | info |
| text input | Input(name=..., inputType=..., value=..., placeholder=...) | inputType not type. No label kwarg (silently dropped). Use Label("...") as a sibling. |
| select | with Select(name=...): SelectOption(label, value=...) | name is the state key; value is what gets sent |
| heading | Heading("text", level=1|2|3|4) | level defaults to 1 |
| state ref | Rx("key") | reads from PrefabApp(state={"key": ...}); supports .then(a, b), .currency(), arithmetic, etc. |
| tool result ref | RESULT or RESULT["nested_key"] | reactive ref to the tool's return value, valid inside onSuccess |
| dynamic list | with ForEach("state_key") as item: ... | item.field resolves at render time |
| Symptom | Cause | Fix |
|---|---|---|
ValidationError: Missing required argument with arguments={} | Form auto-collect didn't fire | Add explicit arguments={"key": Rx("key"), ...} to CallTool and seed state={"key": ...} on PrefabApp |
Unexpected token 's', "[save_trans"... is not valid JSON (or similar host-side parse error) | A print() (or other stdout write) in a stdio server | Remove the print, or send to stderr |
Unknown tool: 'save_thing' from a regular client | Backend tools are hidden from clients by design | Test by calling the Python function directly, not via client.call_tool |
ty errors Argument 'css_class' does not match any known parameter | snake_case kwarg | Switch to camelCase (cssClass) |
Toast renders nothing or app errors with literal_error on variant | Wrong variant for ShowToast | Use error not destructive |
PydanticUserError: TypeAdapter[X] is not fully defined on a Literal-typed prompt arg | from __future__ import annotations defers Literal evaluation | Drop from __future__ import annotations from that module |
| Form submits but backend never sees the field | Field nested inside Field/Card/Grid | Move it to be a direct child of Form |
In-memory only. No port binding.
from fastmcp import Client
from my_pkg.server import mcp
async def test_app_tool() -> None:
async with Client(mcp) as client:
result = await client.call_tool("finance_dashboard", {})
# text content carries the LLM-facing summary from ToolResult
assert "Income" in result.content[0].text
def test_backend_tool_directly() -> None:
# Backend tools are hidden from MCP clients; invoke as a plain function.
from my_pkg.apps import save_thing
result = save_thing(name="x")
assert result["count"] == 1
After adding a Prefab app, the user will want to actually click on it. Remind them: "To try this in Claude Desktop, the server needs an entry in claude_desktop_config.json — want me to add it?" If yes, follow the connecting-to-claude-desktop skill. New tools added later still require a full Cmd-Q + relaunch of Claude Desktop to show up.
mcp__fastmcp__* tools — query them for the latest API when in doubt; Prefab's API is moving fast.Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub vibber-ai/workshop-skills --plugin workshop-skills