Docs navigation

Use your server

MCP Apps

An MCP server can return an interface, not only JSON. A tool declares that a ui:// resource renders its results; the host runs that resource in a sandboxed frame and the interface calls your tools back. foro deploys these with no extra configuration, and the Apps tab is where you drive one and watch what it does.

Several tools, one view

An app is a resource plus every tool that points at it. That is the part people get wrong: it isn't one tool with a UI attached, it's one view that drives a whole workflow. The todo template ships five tools behind a single list.

from fastmcp.apps import AppConfig, ResourceCSP, app_config_to_meta_dict

todo_app = AppConfig(resource_uri="ui://todo/list")

@mcp.resource("ui://todo/list", meta={
    "ui": app_config_to_meta_dict(
        AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"]))
    )
})
def todo_view() -> str:
    return (Path(__file__).parent / "view.html").read_text()

@mcp.tool(app=todo_app)
def add_task(title: str) -> Task: ...

@mcp.tool(app=todo_app)
def list_tasks() -> list[Task]: ...

Nothing else changes. There is no config field to add and no capability to switch on: a server that declares an app is deployed exactly like one that doesn't.

Write the view in React, or by hand

A view is one self-contained HTML document either way. Hand-written is a fine place to start and stays supported - the todo template is exactly that. Past a few interactions you probably want components, and @foro/app is the runtime that makes one React view run in the Apps tab, in Claude, and in ChatGPT without a second build.

views/card.tsx          your component, default-exported
views/dist/card.html    generated, committed, what Python returns

The bundler inlines React, the runtime and your code into that one file, so the view fetches no JavaScript at all when it renders. Your server hands it over like any other resource:

VIEW = Path(__file__).parent / "views/dist/card.html"

@mcp.resource("ui://views/card")
def card_view() -> str:
    return VIEW.read_text()

@mcp.tool(app=AppConfig(resource_uri="ui://views/card"))
def show_card(city: str) -> Weather: ...

Build before you push, and commit the output. The bundle is made on your machine or in your CI, never on ours: a deploy runs your Python and there is no Node step in the image. A forgotten build deploys the previous view, silently and successfully.

Inside the component, hooks are the whole surface. useToolInfo() gives you the input and structuredContent of the call that mounted the view, useCallTool() calls another tool on your server, useViewState() keeps state across a re-render without the localStorage the sandbox denies you, and useLayout() reads what the host has told you: the theme, the height you have, the safe area. foro pushes that context when the view loads and again when you flip the dashboard between light and dark, so a view that reads it follows along. useDisplayMode() can ask for fullscreen; the Apps tab answers inline and logs the request, because it is one pane of a dashboard rather than a canvas to hand over.

One build, more than one host: ext-apps is the language every MCP host speaks, and ChatGPT's window.openai is an accent on top of it. The runtime reads whichever is in the frame, so nothing in your component branches on where it is running.

Writing this with an agent? The design-mcp-view skill in the foro SDK produces this shape - component, build, resource and the _meta that points a tool at it.

Declare every origin, or the frame stays blank

This is the single largest cause of an app that renders nothing. Your app runs with a policy built from what the resource declares, and every directive it doesn't declare is denied. An omitted origin is not a warning, it's a blocked script.

resourceDomains covers scripts, styles, images, fonts and media. connectDomains covers fetch, XHR and WebSockets, and is empty by default, so an app reaches no network at all until you say otherwise. That default is deliberate: it is what bounds an interactive app that could otherwise send data somewhere on its own. Whatever you declare is shown on the Apps tab under the frame, so you and anyone else can read what an app may touch.

Declare where your own bundle comes from too. If your view loads its JavaScript from a CDN, that CDN is an origin like any other. A bundled React view usually has nothing to declare here - its script is inlined in the document - right up until it pulls a font or an image from somewhere.

What the sandbox gives you

Your app runs in an iframe with scripts and forms allowed and no same-origin access. It gets an opaque origin: it cannot reach the dashboard around it, cannot read its cookies, and cannot make an authenticated call to our API. That boundary is the feature, and it has consequences worth designing around before you start rather than after:

  • No localStorage, sessionStorage or IndexedDB. Keep state on your server, where your tools can reach it.
  • No stable origin, so an OAuth callback or an API-key allowlist keyed to one won't work from inside an app.
  • Links open through the host rather than by navigating, and a form submission can't leave the frame.

Data protection and apps

Two different payloads, two different answers. Your app's HTML is source code you wrote, so it is served as-is and never scrubbed - masking it would be the same category error as masking your server.py. The tool calls your app makes are data, and they go through the same scrubber and rate limit as a Playground run, because they are the same request.

A UI resource over roughly 1 MB is refused rather than truncated. A half-delivered HTML document isn't a smaller app, it's a fragment the browser tries to parse, so you get a clear error instead of a mystery.

Watching what it does

The Apps tab shows the tools attached to a view and lights each one up as the app calls it, alongside a log of every call with its arguments, result and timing. It also shows ui/update-model-context - what your app tells the model about what's on screen. That is the half of an MCP App you otherwise debug blind, and it is usually the reason an agent seems not to know what the user just did in the interface.

An app is normally mounted carrying the result of the tool that triggered it. Use “Mount with a tool result” to reproduce that exactly; mounting cold is useful for poking at a view, but an app that renders only from its tool result will look empty until you seed it. In Chat, apps mount the way they will in production: the model calls a tool, the app appears under it with that result.