MCP app frame
Hosts an MCP App (a ui:// resource, text/html;profile=mcp-app) in a sandboxed iframe through the official app bridge: loading, errors, resize to content, chatcn tokens as host styles, full screen, picture in picture, and tool input and output passthrough.
On this page
Installation
This item depends on @modelcontextprotocol/ext-apps and @modelcontextprotocol/client 2.x. Install it only if you host MCP Apps; nothing else in chatcn needs them.
Usage
import { McpAppFrame } from "@/components/ui/chatcn/mcp-app-frame"
// The tool's _meta.ui.resourceUri points at a ui:// resource. Read it from the server first.
const resource = await mcp.readResource({ uri: tool._meta.ui.resourceUri })
<McpAppFrame
resource={{ uri, html: resource.contents[0].text, meta: resource.contents[0]._meta?.ui }}
appName="United"
toolName={part.toolName}
toolInput={part.input}
toolResult={part.output}
onMessage={(text) => send(text)}
onCallTool={(params) => mcp.callTool(params)}
/>The demo's View is a single HTML string with a hand-written bridge, so it runs offline. Real Views usually use the App class from @modelcontextprotocol/ext-apps; either way the frame speaks the same protocol.
Examples
Loading and errors
While the resource is read, the frame holds its height with a static skeleton and says Starting after 300 ms. A failed read and a page that never sends ui/initialize both explain what went wrong and offer Reload app.
Theme bridge
chatcnHostStyles(element) resolves our tokens where the frame sits, light or dark, and sends them as the host style variables Views read with useHostStyles: --color-background-primary from the card surface, --color-text-secondary from muted text, --color-ring-primary from the accent, radii from our scale, and bold capped at 600. When the theme flips, the View gets a host context update and repaints.
Security
- The iframe runs with
allow-scripts allow-formsand popups only, neverallow-same-origin, so the View cannot read the page, its cookies or its storage. - A Content-Security-Policy is injected from the resource's
_meta.ui.csp: network, frames and base URIs are limited to the declared HTTPS domains; everything else is blocked. - Device permissions map to the iframe
allowattribute from_meta.ui.permissions. Links open only for http and https.
Props
| Prop | Type | Notes |
|---|---|---|
resource | { uri, html, meta? } | Undefined while it is being read |
appName, icon, toolName | Header attribution. The icon is your server's mark | |
toolInputPartial, toolInput, toolResult, toolCancelled | Sent to the View in protocol order once it is ready | |
displayMode, defaultDisplayMode, onDisplayModeChange | "inline" | "fullscreen" | "pip" | |
displayModes | McpAppDisplayMode[] | Modes you offer. PiP becomes full screen on small touch screens |
initialHeight, maxHeight | number | Before the View reports its size, and the inline cap |
onMessage | (text, params) => void | ui/message from the View |
onOpenLink, onCallTool, onUpdateModelContext, onClose | Bridge requests | |
error, onRetry, timeoutMs | Resource errors and the initialize timeout |
useChatcnMcpApp is exported from @/hooks/use-chatcn-genui-mcp-app for your own frame UI, with chatcnHostStyles and withCsp.
Keyboard
| Key | Action |
|---|---|
Escape | Leave full screen or picture in picture; focus returns where it was |
Tab | Stays inside the frame in full screen |
Accessibility
- The iframe is titled with the app name. Full screen is a modal dialog; inline and floating frames are labelled regions.
- While the app floats or fills the screen, its place in the answer says so and offers Show it here.
Motion
- The iframe never moves in the DOM, because moving it would reload the app. Display modes restyle it: full screen enters with opacity and scale in 220 ms, picture in picture in 180 ms from its corner. Keyboard-triggered changes are instant.
- Inline height follows the content over 200 ms; under reduced motion it jumps.