MCP app frame
ProShows an MCP App: a tool's own UI, isolated in a sandbox on another origin. Without a usable sandboxProxyUrl it shows a locked notice saying why, and never falls back to running the HTML on this app's origin. The view gets the tool input once it's initialized, then the result (or a cancellation), plus Candor's theme as host styles. Links it asks to open go to onOpenLink or are shown as text to copy; tool calls go through only for callableTools; file downloads and device permissions are not granted.
Preview
Install
First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/mcp-app-frame
Brings in: button, locale, use-copy, utils · npm: @modelcontextprotocol/ext-apps@^2.0.4, lucide-react@^1.67.0
Usage
"use client"
import { McpAppFrame } from "@/components/mcp-app-frame"
// Without a sandbox proxy on another origin, the frame stays locked and says why.
// Host the page from mcpAppSandboxProxyHtml(yourOrigin) at, say, https://sandbox.example.com/proxy.html.
export default function Example() {
return (
<McpAppFrame
className="max-w-lg"
html="<!doctype html><p>Tool UI</p>"
toolInput={{ city: "Oslo" }}
sandboxProxyUrl={undefined}
/>
)
}<McpAppFrame>
| Prop | Type | Default | Description |
|---|---|---|---|
| htmlrequired | string | — | The ui:// resource's HTML (mime text/html;profile=mcp-app). |
| resourceMeta | McpUiResourceMeta | undefined | — | The resource's `_meta.ui`: csp domains, permissions, prefersBorder. |
| toolInput | Record<string, unknown> | undefined | — | The tool call's arguments, sent once the view is ready. |
| toolResult | McpToolResult | undefined | — | The tool's result, sent after the input when it arrives. |
| toolCancelled | string | undefined | — | Set when the tool call was cancelled; the reason is sent to the view. |
| sandboxProxyUrl | string | undefined | — | Your hosted sandbox proxy page (see mcpAppSandboxProxyHtml), on another origin. |
| callableTools | readonly string[] | undefined | — | Tool names the view may call through you (see appCallableTools). Default none. |
| onCallTool | ((name: string, args: Record<string, unknown>) => Promise<McpToolResult>) | undefined | — | Runs a tool the view calls, if it's in callableTools; called with its name and arguments. Without it, calls are refused. |
| onMessage | ((message: McpAppMessage) => void | Promise<void>) | undefined | — | Called when the view asks to add a message to the conversation. Without it, the request is refused. |
| onOpenLink | ((url: string) => void | Promise<void>) | undefined | — | Handle a link the view asks to open. Default: show it for the person to copy. |
| theme | "light" | "dark" | undefined | — | Defaults to the page's Candor theme. |
| title | string | undefined | — | Names the frame for assistive tech; "Tool app" by default. |
| maxHeight | number | undefined | 720 | The tallest the frame grows to, in pixels. |
| hostOrigin | string | undefined | — | This app's origin; defaults to window.location.origin. |
| className | string | undefined | — | Extra classes for the outer element. |
Functions
toolUiResourceUri(tool: ToolLike)The ui:// resource a tool declares for its UI, or undefined. Reads both the current and the deprecated key.
appCallableTools(tools: readonly ToolLike[])The tools a view may call through the host: only those whose _meta.ui.visibility lists "app". A tool that doesn't say is not callable from a view here (default deny), even though the spec's default visibility includes "app"; list it in callableTools yourself if you want it.
sanitizeMcpAppCsp(csp: McpUiResourceCsp | undefined)The resource's declared CSP domains with anything that isn't a plain source removed.
buildMcpAppCsp(csp?: McpUiResourceCsp)The Content-Security-Policy for a view: the spec's restrictive default, widened only by the domains the resource declares (after sanitizing).
injectMcpAppCsp(html: string, policy: string)Puts the policy as the very first thing in the document (after a doctype), so no script in the HTML runs before it.
mcpAppSandboxProxyHtml(hostOrigin: string)The sandbox proxy page. Host it on a different origin from your app (a separate subdomain such as sandbox.example.com is enough) and pass its URL as sandboxProxyUrl. Serve it as text/html with only Content-Security-Policy: frame-ancestors <your app's origin> — the inner frame inherits the page's policy, so don't add other directives there; the view's own policy is injected into its HTML. The page only accepts messages from hostOrigin, runs one resource, and relays JSON-RPC both ways.
checkSandboxProxy(url: string | undefined, hostOrigin: string | undefined, t: McpAppFrameTranslate = english)Whether a proxy URL can be used from this origin: it must be https (http only for localhost) and on a different origin. Returns the proxy's origin, or a sentence saying why not (in English unless you pass McpAppFrame's t).
createMcpAppBridge(o: McpAppBridgeOptions)Makes the host side of the bridge with Candor's policies wired in: the view gets its HTML (with the CSP injected) once the proxy is ready; tool calls go through only for callableTools; links and messages go to your handlers; file downloads are refused. Connect it to a SandboxTransport.
candorHostStyles(el: Element)Candor's theme tokens as MCP Apps style variables, read from an element.
Strings
The fixed words, in English by default. Change any of them under mcpAppFrame in <LocaleProvider strings={{ … }}>; the table is exported as mcpAppFrameStrings.
| Key | English |
|---|---|
| title | Tool app |
| locked | This app can’t be shown safely here. |
| noProxy | No sandbox proxy is set up. MCP Apps run HTML from a tool’s server, so this app only shows them from a sandbox page on a separate origin. |
| invalidProxy | The sandbox proxy address isn’t a valid URL. |
| notHttps | The sandbox proxy must be served over https. |
| noOrigin | This app’s own origin isn’t known yet, so the sandbox can’t be checked. |
| sameOrigin | The sandbox proxy is on this app’s own origin, so the tool’s HTML wouldn’t be isolated from this app. Host it on a separate origin. |
| linkRequest | The app asked to open a link. It wasn’t opened; copy it if you trust it. |
| copyLink | Copy link |
| copied | Copied |
| copyFailed | Couldn’t copy |
| dismiss | Dismiss |
Types
- McpToolResult
- A tool result as MCP sends it.
McpUiToolResultNotification["params"]- McpAppMessage
- What a view asks to add to the conversation.
McpUiMessageRequest["params"]- McpAppBridgeOptions
{ /** The view's HTML (the resource's text). */ html: string resourceMeta?: McpUiResourceMeta | undefined /** Tools the view may call; anything else is refused. */ callableTools?: readonly string[] | undefined onCallTool?: ((name: string, args: Record<string, unknown>) => Promise<McpToolResult>) | undefined /** The view asks to add a message to the conversation. Without a handler it's refused. */