MCP Apps (interactive ui:// resources)

Contents

  • Protocol keys and server pattern

  • App lifecycle

  • Host bridge requests

  • Size and self-diagnosis

  • Host-side notes

Optional level-up: MCP Apps let a tool render an interactive HTML app (plots, forms, 3D viewers) inside the chat. High payoff for simulation tools, but ship the plain tools first — apps only work on hosts that implement the extension, and everything below assumes your tools already work.

Follow the official ext-apps extension (spec 2026-01-26, https://modelcontextprotocol.io/docs/extensions/apps) — not older mcp-ui or OpenAI Apps-SDK conventions.

The spec keys, precisely (people get these wrong)

  • The tool declares _meta.ui.resourceUri = "ui://yourtool/appname" — a bare ui key. The namespaced string io.modelcontextprotocol/ui is the host capability name, NOT the tool _meta key. (A deprecated _meta["ui/resourceUri"] form exists; don’t use it.)

  • The resource is served at that ui:// URI with mimeType: "text/html;profile=mcp-app" and its own _meta.ui object (csp domain lists, permissions, prefersBorder).

  • Tool results need no UI reference — the tools/list declaration is sufficient for the host to mount the app.

  • Hosts declare support at initialize:

    {"capabilities": {"extensions": {
        "io.modelcontextprotocol/ui": {"mimeTypes": ["text/html;profile=mcp-app"]}}}}
    

    Servers should check this (including the mimeTypes list) and degrade gracefully when absent.

Server-side pattern

Per app, register a resource + a zero-argument open_<name> tool:

meta = {"ui": {
    "resourceUri": "ui://yourtool/appname",
    "type": "resource",
    "csp": {"connectDomains": [], "resourceDomains": [],
            "frameDomains": [], "baseUriDomains": []},
    "permissions": {}, "prefersBorder": True,
}}
server.tool(name="open_appname", meta=meta,
            input_schema={"type": "object", "properties": {}, "required": []},
            ...)(open_fn)

Inside open_fn(ctx=None):

  • If the host declared the UI capability → return status: "opened" and a note telling the model the UI is ready and how to push later parameter changes (set_..._inputs), and explicitly: “do NOT emit HTML, iframe markup, or ui:// links”.

  • Otherwise → status: "ui_not_available" and a note saying the UI was NOT rendered, the model must not claim it is visible, and offering the chat-driven fallback tools. Never make the success note unconditional — the server cannot see the host’s screen.

  • Do not put the ui:// URI in the result body (the LLM will write its own iframe; CSP blocks it and the chat shows a broken box).

Tools that only the app itself should call (state polling, example loaders) get _meta.ui.visibility = ["app"] so hosts hide them from the model.

The app lifecycle (spec 2026-01-26)

Your HTML app talks JSON-RPC to the host over postMessage. The app initiates; implement every phase or hosts will consider the app broken.

1. Handshake — app goes first:

app  → host   ui/initialize            (request: appCapabilities, clientInfo)
host → app    ← result                 (hostCapabilities, hostInfo, hostContext)
app  → host   ui/notifications/initialized

Hosts hold back all data until they see ui/notifications/initialized — an app that skips it renders but never receives tool input.

2. Host pushes data (after initialized):

Notification

When

ui/notifications/tool-input-partial

streaming arguments, zero or more

ui/notifications/tool-input

complete arguments — always before the result

ui/notifications/tool-result

the tool call finished; render it

ui/notifications/tool-cancelled

stop spinners, keep state consistent

ui/notifications/host-context-changed

theme, locale, display mode changed

3. App requests to the host (each is a normal JSON-RPC request): tools/call, resources/read, ui/message (post into the chat), ui/open-link, ui/request-display-mode, ui/update-model-context (feed state back to the model for future turns), notifications/message (logging), ping, and the ui/notifications/size-changed notification whenever your content height changes (hosts size the iframe from it).

4. Teardown: the host sends a ui/resource-teardown request (it has an id) before destroying the iframe. Reply — persist state fast if you must — then expect to die. Don’t rely on unload events inside a sandbox.

Lifecycle rules that follow from visibility: hosts refuse tools/call from apps for tools whose _meta.ui.visibility lacks "app", and hide ["app"]-only tools from the model. Default is ["model", "app"].

Be a polite app. Hosts throttle the app→server bridge hard (the com_mcp host coalesces identical in-flight calls, caches identical results for ~5 s, rate-limits per frame, and stretches poll reuse to minutes when the tab is hidden). Design for it: poll with visibilitychange awareness and modest timers, don’t re-call the same tool with the same args in a render loop, and prefer one ["app"]-visibility polling tool with a seq cursor over many chatty reads.

Testable invariant: your generated app HTML must contain the lifecycle strings (ui/initialize, ui/notifications/initialized, ui/notifications/tool-input, ui/resource-teardown handling). Assert this in a validation tool/test so a refactor can’t silently drop the handshake.

Size and self-diagnosis

Apps are typically one self-contained HTML page with inlined JS/CSS — sizes of several MB are real (a Rappture-generated app runs ~6 MB). Keep a configurable size limit (default 8 MB, ..._APP_SIZE_LIMIT_BYTES env) and ship introspection tools (get_..._app_size, validate_..._mcp_app) that render the app, report bytes vs limit, and assert invariants (bridge script present, lifecycle handlers wired). Hosts have their own caps; when a tile doesn’t mount on a host that did declare the capability, size is the first suspect.

Host-side notes (if you also build the host)

  • Render in a sandboxed iframe with a postMessage JSON-RPC bridge; the app initiates with ui/initialize.

  • Declare the capability at initialize (see above) — servers now gate on it.

  • Canonicalize resource URIs before comparing: the nanoHUB gateway prefixes its origin onto ui:// URIs (https://nanohub.org/ui://tool/app); compare from the last non-http(s) scheme marker onward or app frames are never matched and data pushes are dropped.

  • Empty CSP domain lists must survive as [] end-to-end (see the {} vs [] passthrough rule in gateway-cors.md).