show_weather, and a weather dashboard is rendered directly in the conversation bubble. You can tap city tabs to switch between cities, and the bottom of the dashboard displays the temperature and weather conditions of the currently selected city.show_todos, and a Todo list widget appears in the conversation bubble. You can select completed items and add new entries directly in the input box.
Scenario | Issue with Plain Text Solution |
Ask users to confirm a set of configurations. | The model can only list all options for the user to repeat which one to select, making the conversation lengthy. |
Visualized maps / charts / 3D / PDF | Text descriptions suffer from severe information loss, and "Tokyo coordinates 35.6°N, 139.7°E" are far less effective than a map. |
Multi-step forms (budget allocation, parameter fine-tuning) | Repeatedly typing to have the model modify parameters creates a fragmented experience. |
Widgets with persistent state (Todo, media player) | Each operation requires the model to "print the list" again. |
io.modelcontextprotocol/ui protocol extension (spec 2026-01-26), which allows MCP servers to expose HTML widgets as resources, have them rendered by the host in a sandbox, and communicate bidirectionally through structured messages.add_todo), and the results are then pushed back to the widget for partial refresh._meta.ui.resourceUri appears in the tool definition, the tool is associated with a widget.text/html;profile=mcp-app, the resource is the HTML source code of the widget.src of the iframe points to a sandbox proxy page provided by the host (with a different origin from the main page), and the HTML is injected by the host via postMessage.sandbox attribute of the iframe is tightened to allow only script execution and form submission.<meta http-equiv="Content-Security-Policy"> tag in the HTML is declared by the server in the resource metadata and automatically injected by the host.@modelcontextprotocol/ext-apps (GitHub) client library. All cross-boundary calls use JSON-RPC over postMessage, with no DOM or global variables leaking into the main page.Direction | Typical Use | Authorization Required or Not |
Host → Widget: push tool input parameters / tool results / theme switching / display mode changes | widget updates views in real time. | Not involved |
Widget → Host: call other tools on the same server | Trigger tools such as add_todo by clicking a button | Authorize via dialog each time |
Widget → Host: read server resources | widget pulls additional read-only data. | Not required (treated as a safe GET) |
Widget → Host: open external links / trigger downloads / write back messages / inject model context | Feed widget operations to the main conversation | Links are limited to http(s); other operations do not require authorization. |
{ isError: true }, and the server is never actually called.color-scheme: light dark + light-dark(), so the first screen follows the system theme to avoid a white flash.host-context-changed, the widget writes data-theme on the <html> element to explicitly lock it, overriding the CSS auto-detected value.View Tokyo weather in the conversation, the model calls the show_weather tool, and the user selects San Francisco in the widget.User Web UI Host Widget (iframe) MCP Server│ │ │ │ ││ Enter "View Tokyo weather" │ │ │ ││──────────────────────▶│ │ │ ││ │ Pass the prompt to the model │ │ ││ │───────────────────▶│ │ ││ │ │ The model decides to call show_weather │ ││ │ │─────────────────────────────────────────▶ ││ │ │ │ server executes ││ │ │ │ Return toolResult ││ │ │◀──────────────────────────────────────────││ │ │ See _meta.ui │ ││ │ │ Prefetch widget HTML │ ││ │ │──────────────────────────────────────────▶││ │ │◀──────────────────────────────────────────││ │ │ Attach to the tool call message │ ││ │ │ widget metadata │ ││ │◀───────────────────│ │ ││ │ Render sandbox iframe │ │ ││ │ Inject HTML + CSP │ │ ││ │──────────────────────────────────────────▶│ ││ │ │ │ initialize ││ │ │◀─────────────────────│ ││ │ │ Push toolResult │ ││ │ │─────────────────────▶│ ││ │ │ Push hostContext.theme │ ││ │ │─────────────────────▶│ ││ See weather dashboard ✓ │ │ │ Apply theme and render UI ││ │ │ │ ││ Click "San Francisco" │ │ │ ││───────────────────────────────────────────────────────────────────▶│ ││ │ │ │ Call tools/call ││ │ │◀──────────────────────│ get_weather(SF) ││ │ ⚠ Dialog: Allow? │ │ ││ Click "Allow" │ │ │ ││──────────────────────▶│ │ │ ││ │ │ Forward to server │ ││ │ │─────────────────────────────────────────▶ ││ │ │◀──────────────────────────────────────────││ │ │ Return the result to the widget │ ││ │ │─────────────────────▶│ ││ See SF weather ✓ │ │ │ Partial refresh │
host-context-changed event, and the widget updates its own styles.tools/call, the request does not go directly to the server. Instead, it is intercepted by the host, which prompts for authorization. Only resource reads are exempt from authorization.Scenario | Effective or Not |
Web UI (opened in a browser in --serve mode) | ✔ |
Web UI embedded in IDE plugins (VSCode / Fusion / JetBrains) | ✔ |
Terminal TUI ( codebuddy default interactive mode) | ✖ Automatic text downgrade |
Print mode ( -p) | ✖ Automatic text downgrade |
content text of a tool is inherently a fallback for non-visual scenarios.ui://, for example, ui://my-server/dashboardtext/html;profile=mcp-app_meta.ui._meta.ui.resourceUri to the tool definition, pointing to the UI Resource. When the model calls this tool, the host automatically renders the corresponding widget.sandbox_proxy.html, with only allow-scripts allow-same-origin allow-forms permitted. CSP is controlled by the resource's _meta.ui.csp.App instance through the @modelcontextprotocol/ext-apps library (remote ESM import is recommended, while self-hosted ESM or inline IIFE is also supported), and communicates with the host via JSON-RPC over postMessage. All host ↔ guest communication is asynchronous messaging.The server uses the official@modelcontextprotocol/sdk(GitHub). For the_meta.uifield definition, see MCP Apps spec types.
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');const {CallToolRequestSchema,ListToolsRequestSchema,ListResourcesRequestSchema,ReadResourceRequestSchema,} = require('@modelcontextprotocol/sdk/types.js');const UI_MIME = 'text/html;profile=mcp-app';const TODO_URI = 'ui://my-todo/list';const server = new Server({ name: 'my-todo', version: '0.1.0' },{ capabilities: { tools: {}, resources: {} } },);const todos = [];server.setRequestHandler(ListToolsRequestSchema, async () => ({tools: [{name: 'show_todos',description: 'Show interactive todo list widget',inputSchema: { type: 'object', properties: {} },// Key: Declare the UI Resource. When the host sees this field, it renders the widget._meta: { ui: { resourceUri: TODO_URI } },},{name: 'add_todo',description: 'Add a new todo',inputSchema: {type: 'object',properties: { title: { type: 'string' } },required: ['title'],},},],}));server.setRequestHandler(CallToolRequestSchema, async req => {if (req.params.name === 'show_todos') {return {content: [{ type: 'text', text: `${todos.length} todos` }],// structuredContent is automatically pushed to the widget as toolResult.structuredContent: { items: todos },// Key: The tool result also carries _meta.ui, allowing the host to associate it with the widget._meta: { ui: { resourceUri: TODO_URI } },};}if (req.params.name === 'add_todo') {todos.push({ id: todos.length + 1, title: req.params.arguments.title });return {content: [{ type: 'text', text: 'Added' }],structuredContent: { items: todos },};}});server.setRequestHandler(ListResourcesRequestSchema, async () => ({resources: [{ uri: TODO_URI, name: 'todo-list', mimeType: UI_MIME }],}));server.setRequestHandler(ReadResourceRequestSchema, async req => {if (req.params.uri === TODO_URI) {return {contents: [{uri: TODO_URI,text: HTML, // See the HTML template below.mimeType: UI_MIME,_meta: {ui: {// CSP: Declares the external domains required by the widget at runtime, which the host injects into the iframe.// <meta http-equiv="Content-Security-Policy">. **Domains not listed will be blocked by the browser.**csp: {// Allows importing ext-apps remotely via ESM (both script-src and connect-src require esm.sh)resourceDomains: ['https://esm.sh'],// If the widget also calls external APIs (fetch / WebSocket), add the corresponding domains here.connectDomains: ['https://esm.sh'],},permissions: {}, // Default sandbox: allow-scripts allow-same-origin allow-formsprefersBorder: true, // The host adds a 1px border to the iframe for visual distinction.},},}],};}});
<!DOCTYPE html><html><head><meta charset="utf-8" /><!--CSP: Keep it consistent with the domains declared in the resource's _meta.ui.csp.The host automatically injects the script-src / connect-src allowlist based on _meta.ui.csp.However, the <meta> tag written in the HTML takes precedence. It is recommended to declare it as well for convenient local preview.--><meta http-equiv="Content-Security-Policy"content="default-src 'self' 'unsafe-inline';script-src 'self' 'unsafe-inline' https://esm.sh;connect-src 'self' https://esm.sh;img-src 'self' data: blob:;style-src 'self' 'unsafe-inline';"><style>/* Two-level theme adaptation (see the "Theme Adaptation" section for details) */:root {color-scheme: light dark;--bg: light-dark(#fff, #1e1e1e);--fg: light-dark(#1a1a1a, #e6e6e6);}html[data-theme="light"] { color-scheme: light }html[data-theme="dark"] { color-scheme: dark }body { margin: 0; padding: 12px; background: var(--bg); color: var(--fg); }</style></head><body><ul id="list"></ul><script type="module">// Import ext-apps remotely via ESM (a self-contained bundle that the browser can import directly)// Lock the version to 1.x to avoid upstream breaking changes. For production, pin it to a specific patch version (such as @1.7.4).import { App } from 'https://esm.sh/@modelcontextprotocol/ext-apps@1/app-with-deps';const app = new App({name: 'todo-widget',version: '1.0.0',autoResize: true, // Automatically report size-changed based on the content.});function render(items) {const list = document.getElementById('list');list.textContent = '';for (const t of items) {const li = document.createElement('li');li.textContent = t.title;list.appendChild(li);}}// When the model calls show_todos, it pushes the toolResult over.app.ontoolresult = (r) => {if (r?.structuredContent?.items) render(r.structuredContent.items);};// Theme: After obtaining hostContext.theme, write data-theme + style.colorScheme.function applyTheme(theme) {if (!theme) return;document.documentElement.setAttribute('data-theme', theme);document.documentElement.style.colorScheme = theme;}app.onhostcontextchanged = (ctx) => applyTheme(ctx?.theme);await app.connect();applyTheme(app.hostContext?.theme);</script></body></html>
app-with-deps subpath is a browser-friendly bundle with dependencies included in ext-apps. It requires no bundler and is distributed directly by the esm.sh CDN.https://esm.sh/@modelcontextprotocol/ext-apps@1 also works, but esm.sh resolves peerDependencies on your behalf, adding an extra RTT.node_modules/@modelcontextprotocol/ext-apps/dist/src/app-with-deps.js to the static directory of your server. Then, change the HTML to import { App } from '/static/app-with-deps.js', and replace esm.sh with 'self' in the CSP script-src / connect-src directives.app.iife.js into a <script> tag). The HTML becomes large, and after the 256 KB threshold is hit, the host falls back to passing a URI, adding an extra RTT on first screen.{"mcpServers": {"my-todo": {"command": "node","args": ["/path/to/server.js"]}}}
{"mcpServers": {"my-todo": {"type": "http","url": "http://127.0.0.1:8801/mcp"}}}
app.* methods exposed by the @modelcontextprotocol/ext-apps library. The following table lists the protocol methods, their corresponding app.* call entries, whether the host supports them, and the key behaviors.Protocol Method | Library Method ( app.*) | Purpose | host Supported or Not | Key Behavior |
tools/call | app.callServerTool(params) | Call other tools on the same server in reverse | ✔ | Prompts for authorization by default (the host uses _codebuddy.ai/mcpUiCallTool); if the user denies it, { isError: true } is returned; -y / BypassPermissions or when "Always allow" has been selected in this session, the request passes through directly. |
resources/read | app.readServerResource(params) | Read server resources in reverse | ✔ | Read-only, no authorization required |
resources/list | app.listServerResources(params?) | List server resources | ✔ | Forward to the server |
tools/list / prompts/list / resources/templates/list | Generic app.request({ method, params }) | List server tools / prompts / resource templates | ✔ | ext-apps does not provide a dedicated wrapper; use the base class request() directly. |
sampling/createMessage | app.createSamplingMessage(params) | Ask the host to invoke the model once. | ✔ | Use the host's model configuration |
ui/open-link | app.openLink({ url }) | Open the URL in the host browser. | ✔ | Only http:// / https:// is allowed; other schemes are silently rejected. |
ui/message | app.sendMessage({ role, content, _meta? }) | Write messages back to the host conversation. | ✔ | Supports text / image / text+image mixed content. **Default _meta['codebuddy.ai/sendMessageMode'] = 'send'**: injects the message into the main conversation as a user bubble and immediately triggers an agent response (equivalent to the user manually clicking send); when set to 'fill', it only fills the input box (text goes to the textarea, images are added to ImageAttachment) and waits for user confirmation before sending, without triggering the agent. |
ui/download-file | app.downloadFile({ ... }) | Trigger browser download. | ✔ | Pure frontend Blob + <a download>, with no involvement from the host backend. |
ui/update-model-context | app.updateModelContext({ context }) | Inject new context into the agent. | ✔ | Written to the system reminder via ACP and visible to the model on the next call. |
ui/request-display-mode | app.requestDisplayMode({ mode }) | Request switching to inline / fullscreen / pip | ✔ | All three modes work end to end; CodeBuddy patches @mcp-ui/client within the web UI as a fix, while the official mcp-ui version does not support this yet. |
ui/notifications/size-changed | app.sendSizeChanged({ height, width? }) | Report widget content size. | ✔ | Equivalent to autoResize: true; the host expands the inline container accordingly to prevent truncation. |
ui/notifications/request-teardown | app.requestTeardown() | Proactively notify the host that resources have been reclaimed. | ✔ | Notify the backend via ACP to perform cleanup. |
notifications/message(log) | app.sendLog({ level, logger, data }) | Write logs. | ✔ | Forwarded to the host devtools console with the prefix [McpUi guest:<logger>]; level supports debug / info / notice / warning / error / critical / alert / emergency. |
app.on* series of callbacks.Notification | Trigger Timing | widget Receiving Method | Content |
ui/notifications/sandbox-resource-ready | The host loads the widget HTML into the inner iframe. | Handled automatically within the library, with no awareness required from the widget. | { html, sandbox?, csp?, permissions? } |
ui/notifications/host-context-changed | host theme switching / displayMode changes | app.onhostcontextchanged = (ctx) => ... | partial hostContext containing only changed fields |
ui/notifications/tool-input | The model pushes the input parameters to the widget when calling the tool. | app.ontoolinput = (input) => ... | tool input object |
ui/notifications/tool-result | After the model calls the tool, it pushes the result to the widget. | app.ontoolresult = (result) => ... | CallToolResult containing structuredContent |
ui/notifications/tool-cancelled | The tool is cancelled. | app.ontoolcancelled = () => ... | No payload |
McpUiHostContext fields passed through by CodeBuddy (all fields allowed by the spec are delivered):Field | Type | When It Changes | Remarks |
theme | 'light'|'dark' | host user switches theme. | See the "Theme Adaptation" section. |
displayMode | 'inline'|'fullscreen'|'pip' | guest calls requestDisplayMode or host user switches layout. | Used by the widget to determine the layout. |
availableDisplayModes | ('inline'|'fullscreen'|'pip')[] | Unchanged | The host fixes ['inline', 'fullscreen', 'pip']. |
styles | McpUiHostStyles | The variables subobject is updated when the theme switches. | Delivers the CSS variable set parsed by the host (the --cb-* family) to the widget, allowing the widget to directly apply styles through useHostStyles(). |
containerDimensions | { width, height } or { maxWidth, maxHeight } | viewport / displayMode changes | inline provides the max limit (the outer contents cannot obtain the precise parent box); fullscreen / pip provide exact dimensions. |
safeAreaInsets | { top, right, bottom, left } | Unchanged | All 0 on desktop / Web; filled according to env(safe-area-inset-*) when actually integrated on mobile. |
deviceCapabilities | { pointer, hover, ... } | Unchanged | Calculated by (pointer: fine) / (hover: hover) matchMedia. |
locale | BCP 47 string | Unchanged | Obtained from navigator.language |
timeZone | IANA name | Unchanged | Obtained from Intl.DateTimeFormat().resolvedOptions().timeZone |
userAgent / platform | String | Unchanged | Directly pass through browser values. |
toolInfo | { name, description?, ... } | Each time a new tool result arrives | Lets the widget know which tool is currently associated. |
_meta.ui.resourceUri = 'ui://<your-Server>/<id>' to the tool definition.mimeType: 'text/html;profile=mcp-app' and put the HTML in the text field._meta.ui.resourceUri in CallToolResult, and put the data required by the widget in structuredContent.color-scheme: light dark + light-dark() as a fallback. In the script, call new App({ autoResize: true }).connect(), and register app.onhostcontextchanged to handle theme changes and app.ontoolresult to handle tool results.sandbox_proxy.html, with a different origin from the main page).allow-scripts allow-same-origin allow-forms (the host can adjust them through the _meta.ui.permissions resource)._meta.ui.csp resource is injected into the iframe's <meta http-equiv="Content-Security-Policy"> tag.tools/call)tools/call triggers an authorization prompt by default, but two shortcut paths skip the prompt:-y / BypassPermissions startup mode: The user has explicitly declared "skip all permissions" at startup, so all tools (including reverse calls from MCP Apps) are automatically allowed.(server, tool) within this session are allowed directly. The cache expires after /clear or a restart.Default / AcceptEdits / Plan modes, a confirmation prompt is still forced because the user has not explicitly declared blanket permission, so reverse calls from third-party widgets must be confirmed in person. If authorization is denied, the widget receives { isError: true }, and the server is never actually called.resources/read: read-only and requires no authorization (treated as a safe GET).ui/open-link: Only http:// / https:// are allowed, preventing dangerous schemes such as javascript: / data:.ui/download-file: A pure frontend Blob + <a download>, with no involvement from the host backend.AcceptEdits, do not override MCP tools because the host cannot statically determine whether a third-party MCP tool modifies local files or calls remote APIs and incurs charges. If you want certain trusted MCP tools to stop prompting every time, explicitly declare them with allow rules.// ~/.codebuddy/settings.json{"permissions": {"allow": ["mcp__my-todo", // Allow all tools on the entire server."mcp__github__list_issues", // Allow only the list_issues tool on the github server."mcp__github__get_pr_diff"],"deny": ["mcp__github__delete_repo" // Even if the entire server is allowed, a single tool can still be precisely denied.]}}
mcp__<server> — All tools under this server are allowed.mcp__<server>__<tool> — Allow only this tool.deny takes precedence over allow, so you can allow an entire server while blocking a specific dangerous tool.codebuddy --allowed-tools "mcp__my-todo,mcp__github__list_issues" "..."
/clear or a restart. To persist the permission, use the settings approach described above.permissions.allow / --allowed-tools rules apply only to tools that the model actively calls, and do not affect reverse calls from MCP Apps widgets. Reverse calls from widgets go through a separate sandbox approval channel that recognizes only two shortcuts: -y / BypassPermissions startup mode, or having previously selected "Always Allow" in a reverse-call prompt (written to the current session and invalidated after /clear). This design prevents an allow rule configured for a model to use an MCP tool from being silently exploited by a third-party widget for reverse calls.resourceUri, and the guest pulls the resource through onReadResource.AppRenderer of @mcp-ui/client@7.1.1 does not accept an initial hostContext value when the bridge is constructed, and the hostContext that the guest receives on the first ui/initialize is {}. The host later pushes the theme through host-context-changed. Therefore, the widget must implement two-level fallback::root {color-scheme: light dark;--bg: light-dark(#fff, #1e1e1e);--fg: light-dark(#1a1a1a, #e6e6e6);--border: light-dark(#ddd, #3a3a3a);}/* When JS locks data-theme, it overrides the automatic value from light-dark() */html[data-theme="light"] { color-scheme: light }html[data-theme="dark"] { color-scheme: dark }body { background: var(--bg); color: var(--fg); }
light-dark() function requires Chrome 123+ / Safari 17.5+ / Firefox 120+. For details, see MDN: light-dark().Host-context-changed, write data-theme + style.colorScheme to explicitly lock it.function applyTheme(theme) {if (!theme) return; // If theme is not available, do not touch the DOM. Let CSS light-dark() take over as the fallback.document.documentElement.setAttribute('data-theme', theme);document.documentElement.style.colorScheme = theme;}applyTheme(app.hostContext?.theme);app.onhostcontextchanged = (ctx) => applyTheme(ctx?.theme);
useHostStyles() React hook in @modelcontextprotocol/ext-apps (see the comment in useHostStyles.d.ts: *"Apply theme via color-scheme CSS property, enabling light-dark() CSS function support"*). If you write widgets in React, you can call useHostStyles(app, app?.getHostContext()) directly, eliminating the need to handwrite CSS + applyTheme.app.log({ level: 'info', logger: 'my-widget', data: { foo: 'bar' } });
[McpUi guest:my-widget] prefix. The level supports debug / info / notice / warning / error / critical / alert / emergency, and the host maps each level to console.debug / info / warn / error.console / Sources are available and isolated from the main page devtools.tool_call_update._meta['codebuddy.ai'].toolMetaData.mcpUi:acp request, and view the SSE events in the Response.window.__DEBUG_ACP__ = true (if it is enabled in the code).@modelcontextprotocol/ext-apps)spec.types.ts (in the npm package: dist/src/spec.types.d.ts)@modelcontextprotocol/sdk)@mcp-ui/client · @mcp-ui/server)mcp.json / mcpServers field descriptionsnpx for integration testing:Package | Repository Example Directory | Demo Content |
CesiumJS 3D map (OpenStreetMap tiles) | ||
Three.js 3D scene | ||
PDF viewer | ||
Video playback | ||
Interactive Budget Allocation | ||
GLSL shader editor | ||
ABC music score rendering | ||
Wikipedia knowledge graph | ||
Lazy OAuth demo |
Was this page helpful?
You can also Contact sales or Submit a Ticket for help.
Help us improve! Rate your documentation experience in 5 mins.
Feedback