MCP Apps are interactive HTML UIs served by MCP servers and rendered in the user's browser via sandboxed iframes. When a tool has a _meta.ui annotation, mcp-cli launches a local web server that bridges the browser and the MCP backend.
# Install the apps extra (adds websockets dependency)
pip install "mcp-cli[apps]"# Connect to a server that provides app-enabled tools
mcp-cli --server view_demo
# Ask for something visual
> Show me the sales data as a chart
# The browser opens automatically with an interactive chart app
# Tool results are pushed to the app in real-time via WebSocketUse /tools in chat mode to see which tools have app UIs (shown in the APP column).
MCP servers annotate tools with _meta.ui metadata indicating they have an associated UI:
{
"name": "show_chart",
"description": "Display data as an interactive chart",
"_meta": {
"ui": {
"resourceUri": "ui://view-demo/chart",
"mediaType": "text/html"
}
}
}When mcp-cli detects _meta.ui on a tool result, it automatically:
- Fetches the HTML UI resource from the MCP server (via
resources/reador HTTP) - Starts a local HTTP + WebSocket server on an available port (starting from 9470)
- Opens the user's default browser
- Pushes the tool result to the app once the app signals it's ready
Browser Python Backend MCP Server
+-----------------+ +------------------+ +--------------+
| Host Page (JS) |--WS--| AppBridge |--MCP--| Tool Server |
| +-------------+ | | (bridge.py) | | |
| | App iframe | | +------------------+ +--------------+
| | (sandboxed) | | |
| +-------------+ | +------------------+
| postMessage | | AppHostServer |
+-----------------+ | (host.py) |
+------------------+
Components:
host.py(AppHostServer) — Manages app lifecycle: port allocation, HTTP serving (host page + app HTML), WebSocket server, browser launchhost_page.py— JavaScript host page template; bridges iframepostMessageand WebSocket, handlesui/initialize, display modes, reconnection with exponential backoffbridge.py(AppBridge) — JSON-RPC protocol handler: proxiestools/callandresources/readto MCP servers, manages message queue, formats tool results per MCP specmodels.py— Pydantic models:AppInfo,AppState(PENDING -> INITIALIZING -> READY -> CLOSED),HostContext
PENDING -> INITIALIZING -> READY -> CLOSED
- PENDING: App info created, port allocated
- INITIALIZING: WebSocket connected, host page loaded, waiting for app to initialize
- READY: App sent
ui/notifications/initialized, tool results can be pushed - CLOSED: App teardown or browser tab closed
Browser -> Python (inbound):
| Method | Description |
|---|---|
tools/call |
Proxy a tool call to the MCP server |
resources/read |
Proxy a resource read to the MCP server |
ui/message |
App sends a message to the conversation |
ui/update-model-context |
App updates its model context |
ui/notifications/initialized |
App signals it's ready to receive data |
ui/notifications/teardown |
App is shutting down |
Python -> Browser (outbound):
| Method | Description |
|---|---|
ui/notifications/tool-result |
Push tool result data to the app |
ui/notifications/tool-input |
Push tool input arguments to the app |
Apps run in a sandboxed iframe with the following permissions:
allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox
Server-supplied CSP domains are validated against a strict regex (^[a-zA-Z0-9\-.:/*]+$) before being included in CSP directives. This prevents CSP injection attacks.
- Tool names are
html.escape()d before template injection into the host page - All user-supplied content is sanitized at template boundaries
The ui/open-link handler only allows http:// and https:// schemes, blocking javascript: and other dangerous schemes.
The bridge rejects tool names not matching ^[a-zA-Z0-9_\-./]+$ per the MCP spec.
_safe_json_dumps() falls back to _to_serializable() on TypeError/ValueError, with circular reference protection via a visited-object set.
Initial tool results are stored on the bridge and pushed only after the app sends ui/notifications/initialized. This prevents race conditions where postMessage is silently dropped before the app sets up its message listener.
When the WebSocket is disconnected, notifications are queued in a deque(maxlen=50). On reconnect, drain_pending() flushes queued messages.
The host page uses exponential backoff for WebSocket reconnection (1s, 2s, 4s, ... capped at 30s). On successful reconnect, the backoff resets and the bridge state returns to INITIALIZING.
Calling launch_app() for an already-running tool closes the previous instance before launching a new one. If an app is already running, new tool results are pushed to the existing bridge.
A configurable JavaScript timeout (default 30s) shows "App initialization timed out" in the host page status bar if the app never sends ui/notifications/initialized.
ui/initializeresponse includes protocol version, host capabilities (with sandbox details), host info, and host contextui/resource-teardownsent to iframe onbeforeunloadui/notifications/host-context-changedsent after display mode changesstructuredContentrecovered from JSON text blocks when CTP transport normalization discards it
Default values in src/mcp_cli/config/defaults.py:
| Setting | Default | Description |
|---|---|---|
DEFAULT_APP_HOST_PORT_START |
9470 | Starting port for local app servers |
DEFAULT_APP_AUTO_OPEN_BROWSER |
True | Auto-open browser on app launch |
DEFAULT_APP_MAX_CONCURRENT |
10 | Maximum concurrent MCP Apps |
DEFAULT_APP_TOOL_TIMEOUT |
120.0s | Tool call timeout from an app |
DEFAULT_APP_INIT_TIMEOUT |
30s | Initialization timeout (JS-side) |
- Map and video rendering quality depends on the server-side app JavaScript, not mcp-cli
ui/notifications/tool-input-partial(streaming argument assembly) is not yet implemented- HTTPS/TLS for remote deployment is not yet implemented
- CTP transport's
_normalize_mcp_responsediscardsstructuredContent— recovered via text block extraction
See examples/apps/ for working demos:
# Full end-to-end demo (requires: pip install mcp-cli[apps])
python examples/apps/apps_demo.py
# _meta.ui pipeline — shows how metadata survives the tool pipeline
python examples/apps/meta_pipeline_demo.py
# Bridge protocol — demonstrates JSON-RPC message routing
python examples/apps/bridge_protocol_demo.py