If you have built anything on the Model Context Protocol, you already know the shape of a tool. A request goes in, JSON comes back, and the model works through that JSON so it can tell you what it found. The design is simple and it has served the protocol well, but it quietly wastes a lot of tokens, because a single search can return tens of kilobytes of results that exist only so the model can boil them down to a sentence or two.

MCP Apps take a different approach. Instead of handing everything to the model, a tool can return a small interface that the host renders right in the conversation, alongside the structured result it already produces. The data that fills that interface stays in the browser, so the model sees a short summary while the person reading the chat sees an actual table or chart, and nothing has to be paraphrased through the context window to be useful.

This post walks through what MCP Apps are and how to add them to a FastMCP server, starting with a table that takes about ten lines and ending with a dashboard that picks a different layout for each search engine. The screenshots are real rendered output, not mockups.

If you are new to the protocol itself, our overview is the place to start. The rest of this post assumes you have used an MCP server before.

What MCP Apps actually are

MCP Apps are the first official extension to the Model Context Protocol, introduced as SEP-1865 and developed jointly by Anthropic, OpenAI, and the team behind MCP-UI. The idea is modest: alongside the structured result a tool already returns, the server can attach a small HTML interface, which the host then renders inside a sandboxed iframe in the chat.

That small change has a few effects worth spelling out. The person using the client gets a real interface: a sortable table, a chart, a set of cards, instead of a block of JSON, and because the data behind it lives in the browser, the model only ever receives a summary rather than the full payload. Routine interactions like sorting a column or filtering a list also stay on the client, so they cost neither a tool call nor a round trip to the model.

Support is already broad enough to rely on. Claude, ChatGPT, Goose, and VS Code can all render MCP Apps today, and any host that cannot simply falls back to the structured result and ignores the interface, so adding one never breaks an existing client.

Setup

FastMCP provides MCP Apps support through Prefab, a Python component library that you install with the apps extra:

uv add "fastmcp[apps]"

Prefab is still young, and it changes between releases without always preserving backward compatibility. FastMCP does not cap its version, so it is worth pinning Prefab yourself to keep a future release from changing your output unexpectedly:

uv add "fastmcp[apps]" "prefab-ui>=0.20,<0.21"

The most useful part of the workflow is the local preview, which lets you build an App without Claude or any other host:

uv run fastmcp dev apps app.py

That command starts your server and opens a browser with a tool picker. You select a tool, fill in its arguments, and see the interface exactly as a host would render it.

The four ways to build one, and which actually matter

FastMCP offers four levels of MCP App. They increase in both capability and complexity, and most servers need only the first.

Interactive tools are the common case. You mark a tool with app=True and return a Prefab component, which renders as the tool's interface, and sorting, searching, and filtering all happen on the client. This covers the large majority of real use cases, so it is where you should start and, usually, where you should stop.

FastMCPApp is the next step up, for when the interface needs to call back into the server, such as a button that triggers an action or a form that submits data. It lets the interface invoke private backend tools that never appear in the model's tool list. That is worth the extra machinery for write actions, but it is overkill for a read-only view.

Generative UI lets the model write the Prefab layout itself, tailored to the data it has just received. It is flexible and occasionally impressive, but it is also unpredictable, so it is best treated as an experiment rather than a default.

Custom HTML lets you return raw HTML and own the entire surface. That gives you full control along with full responsibility, and it is an escape hatch rather than a starting point.

The rest of this post uses the first pattern, since it is the one you are most likely to reach for.

A results table in about ten lines

Here is the smallest App that does something useful. It takes search results and renders them as an interactive table.

from typing import Any

from fastmcp import FastMCP
from prefab_ui.app import PrefabApp
from prefab_ui.components import Column, DataTable, DataTableColumn

mcp = FastMCP("MCP Apps demo")

COLUMNS = [
    DataTableColumn(key="position", header="#", sortable=True, width="56px"),
    DataTableColumn(key="title", header="Title", sortable=True),
    DataTableColumn(key="source", header="Source", sortable=True),
    DataTableColumn(key="snippet", header="Snippet"),
]


@mcp.tool(app=True)
def search_table(params: dict[str, Any] | None = None) -> PrefabApp:
    rows = organic_rows(fetch_search_data(params))  # plain list[dict]
    with PrefabApp(title="Search results") as app:
        with Column(gap=4, css_class="p-4"):
            DataTable(columns=COLUMNS, rows=rows, search=True, paginated=True, page_size=10)
    return app

Two things are worth noticing here. The app=True flag does all the work, since it tells FastMCP to register a UI renderer for the tool and advertise it to the host; without it, the component would simply serialize back to JSON. The data, meanwhile, is an ordinary list of dictionaries, so there is no special serialization step. You shape your data to match what the component expects and let Prefab handle the rest.

The result is a live table, with headers that sort, a filter box that searches, and working pagination, none of which costs another tool call.

An interactive, sortable, searchable results table rendered from search_table

One design decision: a new tool, not a mode flag

A natural first instinct is to add a mode="ui" argument to an existing search tool and return an interface when it is set. It is worth resisting that.

The app=True flag is static metadata. FastMCP advertises a tool as a UI tool when it lists the available tools, before any call is made, so there is no per-call switch to flip. Adding UI to your existing search tool would change its behavior for every client, including the ones that only want JSON.

A separate, opt-in tool avoids the problem entirely. You leave search untouched and add search_table alongside it. Hosts that support Apps can offer the richer tool, and every other client keeps the one it already had.

A real dashboard

A single table is a good start, but Prefab can compose a complete layout out of metric cards, a chart, and a table that responds to clicks. The dashboard below summarizes a search, shows where its results come from, and reveals a detail panel when you select a row.

from prefab_ui.actions import SetState
from prefab_ui.components import (
    Card, CardContent, CardHeader, Column, DataTable, Grid, H3, If, Link, Metric, Small, Text,
)
from prefab_ui.components.charts import PieChart
from prefab_ui.rx import STATE, Rx


def build_dashboard_app(data: dict[str, Any]) -> PrefabApp:
    params = data.get("search_parameters") or {}
    rows = organic_rows(data)
    sources = source_breakdown(rows)            # [{"source": ..., "count": ...}]
    total = (data.get("search_information") or {}).get("total_results")

    with PrefabApp(title="Search dashboard", state={"selected": None}) as app:
        with Column(gap=4, css_class="p-4"):
            with Grid(columns=[1, 1, 1], gap=4):
                Metric(label="Query", value=params.get("q", "—"))
                Metric(label="Results shown", value=str(len(rows)))
                Metric(label="Total matches", value=f"{total:,}" if isinstance(total, int) else "—")

            with Grid(columns=[1, 2], gap=4):
                PieChart(data=sources, data_key="count", name_key="source", show_legend=True)
                DataTable(
                    columns=COLUMNS,
                    rows=rows,
                    search=True,
                    on_row_click=SetState("selected", Rx("$event")),
                )

            # Renders only after a row is clicked — entirely client-side.
            with If(STATE.selected):
                with Card():
                    with CardHeader():
                        H3(Rx("selected.title"))
                        Small(content=Rx("selected.source"))
                    with CardContent():
                        with Column(gap=2):
                            Text(content=Rx("selected.snippet"))
                            Link(content=Rx("selected.link"), href=Rx("selected.link"), target="_blank")
    return app

The reactive state is the part worth understanding. PrefabApp(state={"selected": None}) declares a piece of interface state, the table's on_row_click writes the selected row into it with SetState, and the If(STATE.selected) block renders the detail card once a row is chosen. Clicking a row updates the panel immediately, with no server involvement and no tokens spent.

A dashboard with metric cards, a source breakdown pie chart, and a results table

Scaling across engines

Once you have a dashboard, it becomes clear that different searches call for different views. A list of organic links has little in common with a set of products that carry prices and ratings, and forcing both through one generic layout serves neither of them well.

A small map from engine to builder function handles this cleanly:

ENGINE_APP_BUILDERS: dict[str, Callable[[dict], PrefabApp]] = {
    "google_shopping": build_shopping_app,
}


@mcp.tool(app=True)
def search_dashboard(params: dict[str, Any] | None = None) -> PrefabApp:
    data = fetch_search_data(params)
    engine = (params or {}).get("engine", "google")
    builder = ENGINE_APP_BUILDERS.get(engine, build_dashboard_app)
    return builder(data)

With the map in place, adding an engine-specific dashboard is a single line, and any engine you have not registered falls back to the generic dashboard, so there is no gap in coverage. The shopping build below uses the same pieces arranged for products, with a price bar chart and currency formatting.

A shopping dashboard with product metrics, a price bar chart, and a product table

build_shopping_app is simply another function that returns a PrefabApp. It reaches for BarChart instead of PieChart and adds a currency column format, but its structure matches the generic dashboard. That is the point of the pattern, since a new vertical means shaping new data rather than building new plumbing.

A few things to keep in mind

The first is to keep your plain JSON tool. MCP Apps are additive, so the search tool that returns JSON should stay exactly as it is, both for hosts without App support and for agents that genuinely want the raw data.

The second is to pin Prefab, for the reason mentioned earlier. It moves quickly, and an unpinned dependency will eventually surprise you.

The third is to test the data rather than the rendering. The functions that shape your rows, such as organic_rows and source_breakdown, are pure and easy to test, while the rendering itself is Prefab's responsibility. Keeping your logic in small, testable helpers lets the components stay thin.

Wrapping up

MCP Apps change what a tool is allowed to return. With a modest amount of FastMCP and Prefab, a search tool can hand back a sortable table, a dashboard, or a layout tailored to a particular engine, and the data behind it never has to pass through the model's context window.

The SerpApi MCP server exposes exactly these kinds of search results, which makes it a natural place to try this out. You can connect to the hosted server at https://mcp.serpapi.com/YOUR_API_KEY/mcp, or run it yourself from the open-source repository. If you have not set it up before, our prior MCP introduction covers the basics, and then MCP engine schemas deep dive goes deeper on getting good tool calls.