MCP in Practice

Lesson 27 of 36

MCP Apps I: a tool that answers with an interface

Some answers are a grid

"What does the floor look like next week?" has a bad text answer. Three rooms, five days, ten hours each — a hundred and fifty cells. Rendered as prose it is unreadable; summarised it loses the thing the question was asking about, which is the shape of the week and where the gaps are.

Every other tool in this course returns text or structured data for the model to phrase. This one wants a grid the user looks at, points at, and clicks.

MCP Apps is the extension for that: a tool result can carry its own interface, which the host renders.

A display case in a shop you do not own

A department store lets a brand install a display case on its floor. The brand controls what is inside — the layout, the lighting, the products. They cannot touch the store's shelves, its till, or its stockroom. The glass is the boundary, and it is the store's glass.

For a beginner that is the whole idea: you get a space to show something, inside somebody else's premises. For an engineer it is a sandboxed iframe with a deny-by-default Content Security Policy, and the analogy predicts the constraints correctly — you cannot reach the host's DOM, you cannot read its storage, and you cannot load whatever you like from outside.

How a tool declares one

Two pieces. A resource serving the interface, at a ui:// URI:

python
@mcp.resource("ui://atrium/availability-grid", mime_type="text/html")
def availability_grid_ui() -> str:
    """The interface for the weekly availability grid."""
    return (BUILD_DIR / "availability-grid.html").read_text()

And a tool that points at it through _meta:

python
@mcp.tool(
    title="Weekly availability",
    meta={"ui": {"resourceUri": "ui://atrium/availability-grid"}},
)
def week_availability(week_of: str, ctx: Context[AtriumContext] = None) -> dict:
    """Availability for every room across a week. Renders as a grid the user can book from."""
    store = ctx.request_context.lifespan_context.bookings
    return {
        "week_of": week_of,
        "rooms": list(ROOMS),
        "grid": {room: store.week_grid(room, week_of) for room in ROOMS},
    }

The ui:// scheme is what tells a host this resource is an interface rather than data. The path after it is yours to organise.

When the host calls week_availability, it fetches the ui:// resource, renders it in a sandboxed iframe, and delivers the tool result into it.

What the sandbox admits and what it refuses, and why an app gets no privileged path
What the sandbox admits and what it refuses, and why an app gets no privileged path

The tool still returns real data. That matters: a host with no MCP Apps support gets a perfectly good structured result and shows it however it normally would. The interface is an enhancement, not a replacement, and building it the other way round — a tool that returns nothing useful without its UI — breaks every client that does not support the extension.

The sandbox, and what it forbids

The iframe is deny-by-default. Nothing loads from outside unless you configure it explicitly — no CDN script, no Google Font, no remote image, no fetch to your own API.

The practical answer is to bundle everything into one HTML file. Inline the CSS, inline the JavaScript, embed images as data URIs. The tooling for this is ordinary; a bundler with a single-file plugin does it in one build step, which is why the resource above reads a built file rather than serving a template.

You can configure the CSP to permit specific origins if you must. Consider carefully first: every origin you allow is one the user's host now loads code from on your behalf, and a display case that phones home is exactly what the glass is there to prevent.

Designing for a box you do not control

The host decides the width, the height, the theme and the surrounding chrome. You get a rectangle whose dimensions you learn at runtime, if at all.

  • Assume narrow. A grid that needs 900 pixels will be shown in 380. Design the mobile layout first, or accept horizontal scrolling inside your own box.
  • Assume both themes. The host may be light or dark. Use colours that survive both, or read the theme if the host offers it.
  • Assume latency. Every action from the interface is a round trip to your server. Show the pending state.
  • Degrade honestly. If the interface fails to render, the tool result must still be a useful answer.

When it is worth it

An interface is a real cost — a build step, a bundle, a second thing to maintain, and a feature that only works on hosts supporting the extension. The test is whether the answer is genuinely spatial or interactive.

A grid where the pattern of gaps is the information: worth it. A seat map, a chart, a diff, a picker over dozens of options: worth it. A list of three bookings: not worth it, and a table in text is better in every way.

Atrium's grid passes because the reason to look at a week is to find a gap, and finding a gap in prose means holding a hundred and fifty cells in your head.

What to take into the next lesson

An MCP App is a ui:// resource serving self-contained HTML plus a tool that points at it through _meta.ui.resourceUri; the host renders it in a deny-by-default sandbox, so bundle everything and design for a narrow box in an unknown theme. The tool must still return real data for hosts without the extension. Next: making the grid respond.

← Previous