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 bareuikey. The namespaced stringio.modelcontextprotocol/uiis the host capability name, NOT the tool_metakey. (A deprecated_meta["ui/resourceUri"]form exists; don’t use it.)The resource is served at that
ui://URI withmimeType: "text/html;profile=mcp-app"and its own_meta.uiobject (cspdomain lists,permissions,prefersBorder).Tool results need no UI reference — the
tools/listdeclaration 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 |
|---|---|
|
streaming arguments, zero or more |
|
complete arguments — always before the result |
|
the tool call finished; render it |
|
stop spinners, keep state consistent |
|
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).