Skip to content

MCP Apps Integration Guide โ€‹

Let MCP tools go beyond plain text โ€” render an interactive Widget directly in the conversation: buttons, forms, maps, PDFs, 3D scenes โ€” all embedded in the CodeBuddy Web UI.

This document is for third-party MCP server developers. If you already have an MCP server and want to add a widget UI to a tool, this guide covers the protocol contract, host capabilities, and integration steps.

Preview โ€‹

The screenshot below is from an MCP Apps integration demo: the user triggered two tool calls with widgets in succession.

  • The first message, "Check the weather in Tokyo," invoked show_weather, rendering a weather dashboard directly in the conversation bubble: users can click city tabs to switch, and the bottom area displays the temperature and weather conditions for the selected city.
  • The second message, "Check my todos," invoked show_todos, showing a Todo list widget in the bubble: users can check off completed items and add new entries directly in the input field.

MCP Apps Preview: Weather and Todo widgets

The entire interaction happens without leaving the conversation window โ€” the widget is the tool's "return value," and user actions within the widget can trigger tools in reverse, allowing the model to pick up the updated state and continue.

Background: Why MCP Apps โ€‹

MCP (Model Context Protocol) native tool calls are plain text in, plain text out: the model sends a JSON input, the server returns text/structured data, and it ends up as a few lines of text or a code block in the conversation bubble. This contract works fine for "reading docs, querying databases, running scripts," but becomes awkward in scenarios like these:

ScenarioProblem with Plain Text
Have the user confirm a set of configurationsThe model can only list all options and ask the user to type back their choice โ€” verbose conversation
Visualize maps / charts / 3D / PDFsText descriptions lose too much โ€” "Tokyo coordinates 35.6ยฐN, 139.7ยฐE" is far less useful than a map
Multi-step forms (budget allocation, parameter tuning)Going back and forth typing parameters for the model to adjust โ€” fragmented experience
Stateful Widgets (Todo, player)Every action requires the model to "print the list" again

The community's answer is MCP Apps โ€” the io.modelcontextprotocol/ui protocol extension (spec 2026-01-26): it lets MCP servers expose HTML widgets as resources, which the host renders in a sandbox with structured bidirectional messaging.

CodeBuddy Code integrates this capability into its Web UI, so any third-party server following the MCP Apps spec works "out of the box" in CodeBuddy.

What Problems It Solves โ€‹

From a CodeBuddy user's perspective, this integration solves three things:

  1. Tool result visualization: When the model calls an MCP tool that declares a widget, CodeBuddy automatically renders the widget in the conversation bubble โ€” no front-end integration required from the tool author.
  2. Interactive results: Buttons/forms within the widget can trigger other tools on the same server in reverse (e.g., clicking "Add" directly calls add_todo), and the result is pushed back for partial widget refresh.
  3. Secure and controllable extension point: Third-party HTML runs in a strictly isolated sandbox, reverse tool calls require user authorization via popup, and the main page state is never contaminated.

How It Works โ€‹

Protocol Contract (No Changes to MCP Core Protocol) โ€‹

MCP Apps is a spec extension, not a new protocol. CodeBuddy only "recognizes" it in two places:

  • A tool definition contains _meta.ui.resourceUri โ†’ this tool is associated with a widget.
  • A resource has MIME type text/html;profile=mcp-app โ†’ this resource is the widget's HTML source.

The model calls tools as usual, and the server responds as usual; it's just that when the host sees these two markers, it takes an extra step to "load the HTML into a sandbox and push tool results to the widget."

Rendering Pipeline โ€‹

CodeBuddy runs widgets in a cross-origin sandbox iframe:

  • The iframe's src points to a host-provided sandbox proxy page (with a different origin from the main page), and HTML is injected via postMessage
  • The iframe's sandbox attribute is tightened to only allow script execution and form submission
  • The <meta http-equiv="Content-Security-Policy"> in the HTML is declared by the server in the resource metadata, and the host injects it automatically

The widget runtime communicates with the host through the @modelcontextprotocol/ext-apps (GitHub) client library โ€” all cross-boundary calls are JSON-RPC over postMessage, with no DOM or global variable leaks to the main page.

Bidirectional Communication โ€‹

DirectionTypical UseAuthorization Required
Host โ†’ Widget: push tool input / tool result / theme switch / display mode changeWidget updates its view in real timeN/A
Widget โ†’ Host: call other tools on the same serverClick a button to trigger add_todo, etc.Popup authorization each time
Widget โ†’ Host: read server resourcesWidget fetches additional read-only dataNot required (treated as a safe GET)
Widget โ†’ Host: open external link / trigger download / write back message / inject model contextFeed widget actions back to the main conversationLinks limited to http(s); others not required

Key security constraint: Reverse tool calls always go through the user authorization popup. When rejected, the widget receives { isError: true }, and the server is never actually called.

Two-Layer Theme Adaptation โ€‹

The Web UI supports light/dark theme switching, and widgets must follow suit. However, the host has not yet sent the current theme to the widget at initialization time โ€” there is a window where hostContext is an empty object during first render.

CodeBuddy's approach is a two-layer fallback:

  1. CSS layer: The widget's default CSS uses color-scheme: light dark + light-dark(), following the system theme on first render to avoid a white flash
  2. JS layer: After the host pushes the theme via host-context-changed, the widget sets data-theme on <html> to explicitly lock the theme, overriding the CSS automatic value

The integration documentation includes ready-made templates (see the "Theme adaptation best practice" section below) that third-party server authors can copy directly.

A Complete Loading & Interaction Flow โ€‹

Taking "the user types Check the weather in Tokyo โ†’ the model calls the show_weather tool โ†’ the user switches to San Francisco in the widget" as an example:

User                    Web UI                Host                Widget(iframe)         MCP Server
 โ”‚                        โ”‚                    โ”‚                       โ”‚                    โ”‚
 โ”‚ Type "Check Tokyo      โ”‚                    โ”‚                       โ”‚                    โ”‚
 โ”‚ weather"               โ”‚                    โ”‚                       โ”‚                    โ”‚
 โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚                    โ”‚                       โ”‚                    โ”‚
 โ”‚                        โ”‚  Forward prompt    โ”‚                       โ”‚                    โ”‚
 โ”‚                        โ”‚  to model          โ”‚                       โ”‚                    โ”‚
 โ”‚                        โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚                       โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚ Model decides to call โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚ show_weather           โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ โ”‚
 โ”‚                        โ”‚                    โ”‚                       โ”‚   Server executes  โ”‚
 โ”‚                        โ”‚                    โ”‚                       โ”‚   returns toolResultโ”‚
 โ”‚                        โ”‚                    โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚
 โ”‚                        โ”‚                    โ”‚ Sees _meta.ui          โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚ Pre-fetches widget HTMLโ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ โ”‚
 โ”‚                        โ”‚                    โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚
 โ”‚                        โ”‚                    โ”‚ Attaches widget       โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚ metadata to tool call โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚ message               โ”‚                    โ”‚
 โ”‚                        โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚                       โ”‚                    โ”‚
 โ”‚                        โ”‚ Renders sandbox    โ”‚                       โ”‚                    โ”‚
 โ”‚                        โ”‚ iframe             โ”‚                       โ”‚                    โ”‚
 โ”‚                        โ”‚ Injects HTML + CSP โ”‚                       โ”‚                    โ”‚
 โ”‚                        โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚                       โ”‚  initialize        โ”‚
 โ”‚                        โ”‚                    โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚ Pushes toolResult     โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚ Pushes                โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚ hostContext.theme      โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚                    โ”‚
 โ”‚ Sees weather dashboard โœ“โ”‚                    โ”‚                       โ”‚ Applies theme,     โ”‚
 โ”‚                        โ”‚                    โ”‚                       โ”‚ renders UI          โ”‚
 โ”‚                        โ”‚                    โ”‚                       โ”‚                    โ”‚
 โ”‚ Clicks "San Francisco"  โ”‚                    โ”‚                       โ”‚                    โ”‚
 โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚                       โ”‚ Calls tools/call   โ”‚
 โ”‚                        โ”‚                    โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚ get_weather(SF)    โ”‚
 โ”‚                        โ”‚ โš  Popup: Allow?    โ”‚                       โ”‚                    โ”‚
 โ”‚ Clicks "Allow"          โ”‚                    โ”‚                       โ”‚                    โ”‚
 โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚                    โ”‚                       โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚ Forwards to server    โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ โ”‚
 โ”‚                        โ”‚                    โ”‚โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚
 โ”‚                        โ”‚                    โ”‚ Sends result back     โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚ to widget             โ”‚                    โ”‚
 โ”‚                        โ”‚                    โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚                    โ”‚
 โ”‚ Sees SF weather โœ“       โ”‚                    โ”‚                       โ”‚ Partial refresh    โ”‚

Key points:

  1. First load is host-initiated pre-fetch: HTML is injected into the iframe by the host in one shot, avoiding an extra RTT for the widget to fetch resources after startup (when HTML exceeds 256 KB, it degrades to passing the URI, and the widget fetches it on its own).
  2. toolResult is pushed by the host, not pulled by the widget: Every time the model calls a tool associated with a widget, the result is automatically pushed to the corresponding iframe, keeping the widget state in sync with the conversation context.
  3. Theme uses a notification channel: When the host switches themes, it doesn't recreate the iframe โ€” it only pushes a host-context-changed notification, and the widget swaps styles on its own.
  4. Reverse tool calls always go through the user: When the iframe calls tools/call, it doesn't go directly to the server โ€” it's intercepted by the host with an authorization popup; only resource reads are authorization-free.
  5. Historical widgets use placeholders by default: When refreshing to an old conversation, widgets in history are not automatically reloaded โ€” a lightweight placeholder badge is shown, and the user clicks to load, avoiding mounting dozens of iframes at once.

Applicability โ€‹

ScenarioSupported
Web UI (--serve mode with browser)โœ…
Web UI embedded in IDE plugins (VSCode / Fusion / JetBrains)โœ…
Terminal TUI (codebuddy default interactive mode)โŒ Auto text fallback
Print mode (-p)โŒ Auto text fallback

In terminal mode, widget authors don't need special handling โ€” the tool's content text is already the fallback for non-visual scenarios.


Integration Guide โ€‹

Core Protocol Concepts โ€‹

UI Resource โ€‹

An MCP Apps widget is an HTML page exposed as an MCP Resource:

  • URI must start with ui://, e.g., ui://my-server/dashboard
  • MIME must be text/html;profile=mcp-app
  • The resource's _meta.ui can declare CSP / permissions / border preferences

App Tool โ€‹

To let the host know that a tool is associated with a widget, add _meta.ui.resourceUri pointing to a UI Resource in the tool definition. When the model calls this tool, the host automatically renders the corresponding widget.

Sandbox iframe โ€‹

The host runs widget HTML in a cross-origin sandbox iframe, isolated from the main page. The iframe is proxied through the host-provided sandbox_proxy.html and only allows allow-scripts allow-same-origin allow-forms. CSP is controlled by the resource's _meta.ui.csp.

AppBridge / postMessage โ€‹

Widget HTML obtains an App instance through the @modelcontextprotocol/ext-apps library (remote ESM import recommended; self-hosted ESM or IIFE inline also supported) and communicates with the host via JSON-RPC over postMessage. All host โ†” guest communication is asynchronous messaging.

Minimal Integration Example โ€‹

Below is skeleton code (Node.js MCP server, stdio transport):

1. Server Side โ€‹

The server uses the official @modelcontextprotocol/sdk (GitHub). _meta.ui field definitions can be found in the MCP Apps spec types.

javascript
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 UI Resource; the host renders the widget when it sees this field
      _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 as toolResult to the widget
      structuredContent: { items: todos },
      // Key: tool result also carries _meta.ui to let the host 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 HTML template below
        mimeType: UI_MIME,
        _meta: {
          ui: {
            // CSP: Declare external domains required by the widget runtime. The host
            // injects them into the iframe's <meta http-equiv="Content-Security-Policy">.
            // **Unlisted domains will be blocked by the browser.**
            csp: {
              // Allow remote ESM import of ext-apps (both script-src and connect-src need esm.sh)
              resourceDomains: ['https://esm.sh'],
              // If the widget also calls external APIs (fetch / WebSocket), add domains here
              connectDomains: ['https://esm.sh'],
            },
            permissions: {},     // Default sandbox: allow-scripts allow-same-origin allow-forms
            prefersBorder: true, // Host adds a 1px border to the iframe for visual distinction
          },
        },
      }],
    };
  }
});

2. HTML Template โ€‹

html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />

  <!--
    CSP: Keep in sync with domains declared in the resource's _meta.ui.csp.
    The host automatically injects script-src / connect-src allowlists based on _meta.ui.csp,
    but the <meta> in HTML takes precedence. It's recommended to declare them here 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-layer theme adaptation (see "Theme adaptation" section) */
    :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">
    // Remote ESM import of ext-apps (self-contained bundle with dependencies, directly importable by browsers)
    // Lock to 1.x to avoid upstream breaking changes; for production, pin to a specific patch version (e.g., @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 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, the toolResult is pushed here
    app.ontoolresult = (r) => {
      if (r?.structuredContent?.items) render(r.structuredContent.items);
    };

    // Theme: After receiving hostContext.theme, set 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>

About ESM Import Methods

  • The app-with-deps sub-path is a browser-friendly bundle with ext-apps dependencies included, no bundler needed, distributed directly via the esm.sh CDN
  • The default entry https://esm.sh/@modelcontextprotocol/ext-apps@1 also works, but esm.sh will resolve peerDependencies on your behalf, adding an extra RTT
  • For offline/intranet environments where esm.sh is inaccessible: copy node_modules/@modelcontextprotocol/ext-apps/dist/src/app-with-deps.js to your server's static directory, change the HTML to import { App } from '/static/app-with-deps.js', and replace esm.sh with 'self' in the CSP's script-src / connect-src
  • Not recommended: the legacy IIFE inline approach (embedding the entire app.iife.js inside a <script> tag) โ€” the HTML size is large, and when it hits the 256 KB threshold, the host degrades to passing the URI, adding an extra RTT for first render

3. Register in mcp.json โ€‹

json
{
  "mcpServers": {
    "my-todo": {
      "command": "node",
      "args": ["/path/to/server.js"]
    }
  }
}

Or with HTTP transport:

json
{
  "mcpServers": {
    "my-todo": {
      "type": "http",
      "url": "http://127.0.0.1:8801/mcp"
    }
  }
}

Host Capabilities โ€‹

Guest โ†’ Host Protocol Methods โ€‹

The widget calls the host via app.* methods exposed by the @modelcontextprotocol/ext-apps library. The table below lists protocol methods, corresponding app.* call entries, host support status, and key behaviors.

Protocol MethodLibrary Method (app.*)PurposeHost SupportedKey Behavior
tools/callapp.callServerTool(params)Reverse call to other tools on the same serverโœ…Popup authorization by default (host goes through _codebuddy.ai/mcpUiCallTool); if user rejects, returns { isError: true }; passes through directly with -y / BypassPermissions or if "Always allow" was selected in this session
resources/readapp.readServerResource(params)Reverse read of server resourcesโœ…Read-only, no authorization required
resources/listapp.listServerResources(params?)List server resourcesโœ…Forwarded to server
tools/list / prompts/list / resources/templates/listGeneric app.request({ method, params })List server tools / prompts / resource templatesโœ…ext-apps doesn't provide dedicated wrappers; use the base request() method
sampling/createMessageapp.createSamplingMessage(params)Ask the host to make a model callโœ…Uses the host's model configuration
ui/open-linkapp.openLink({ url })Open URL in the host browserโœ…Only allows http:// / https://; other schemes are silently rejected
ui/messageapp.sendMessage({ role, content, _meta? })Write a message back to the host conversationโœ…Supports text / image / text+image mixed content. Default _meta['codebuddy.ai/sendMessageMode'] = 'send': injects into the main conversation as a user bubble and immediately triggers an agent response (equivalent to the user pressing send); set to 'fill' to only fill the input box (text goes to textarea, images append to ImageAttachment) and wait for user confirmation before sending, without triggering the agent
ui/download-fileapp.downloadFile({ ... })Trigger browser downloadโœ…Pure front-end Blob + <a download>; host backend not involved
ui/update-model-contextapp.updateModelContext({ context })Inject new context for the agentโœ…Written to the system reminder via ACP; visible on the next model call
ui/request-display-modeapp.requestDisplayMode({ mode })Request switching to inline / fullscreen / pipโœ…All three modes fully supported; CodeBuddy patches @mcp-ui/client in the web-ui to fix this โ€” the official mcp-ui version does not yet support it
ui/notifications/size-changedapp.sendSizeChanged({ height, width? })Report widget content dimensionsโœ…Equivalent to autoResize: true; the host uses this to expand the inline container to avoid truncation
ui/notifications/request-teardownapp.requestTeardown()Proactively notify the host that resources have been cleaned upโœ…Notifies the backend to clean up via ACP
notifications/message (log)app.sendLog({ level, logger, data })Send logsโœ…Forwarded to host devtools console with prefix [McpUi guest:<logger>]; level supports debug / info / notice / warning / error / critical / alert / emergency

Host โ†’ Guest Push Notifications โ€‹

Events actively pushed by the host to the widget, received via app.on* callbacks.

NotificationTriggerWidget ReceptionContent
ui/notifications/sandbox-resource-readyHost loads widget HTML into inner iframeHandled automatically by the library; transparent to widget{ html, sandbox?, csp?, permissions? }
ui/notifications/host-context-changedHost theme switch / displayMode changeapp.onhostcontextchanged = (ctx) => ...Partial hostContext, containing only changed fields
ui/notifications/tool-inputModel calls a tool and pushes input to widgetapp.ontoolinput = (input) => ...Tool input object
ui/notifications/tool-resultModel tool call completes and pushes result to widgetapp.ontoolresult = (result) => ...CallToolResult, including structuredContent
ui/notifications/tool-cancelledTool is cancelledapp.ontoolcancelled = () => ...No payload

hostContext Fields โ€‹

McpUiHostContext fields passed through by CodeBuddy (all spec-allowed fields are sent):

FieldTypeWhen It ChangesNotes
theme'light'|'dark'Host user switches themeSee the "Theme adaptation" section
displayMode'inline'|'fullscreen'|'pip'Guest calls requestDisplayMode or host user switches layoutWidget uses this to determine layout
availableDisplayModes('inline'|'fullscreen'|'pip')[]Does not changeHost always returns ['inline', 'fullscreen', 'pip']
stylesMcpUiHostStylesvariables sub-object updates on theme switchSends the host's parsed CSS variable set (--cb-* family) so the widget can use useHostStyles() to apply styles directly
containerDimensions{ width, height } or { maxWidth, maxHeight }viewport / displayMode changeInline gives max limits (the outer contents container can't get exact parent box dimensions); fullscreen / pip gives exact dimensions
safeAreaInsets{ top, right, bottom, left }Does not changeAll zeros on desktop / Web; filled with real values from env(safe-area-inset-*) on mobile
deviceCapabilities{ pointer, hover, ... }Does not changeComputed from (pointer: fine) / (hover: hover) matchMedia
localeBCP 47 stringDoes not changeTaken from navigator.language
timeZoneIANA nameDoes not changeTaken from Intl.DateTimeFormat().resolvedOptions().timeZone
userAgent / platformStringDoes not changeBrowser values passed through directly
toolInfo{ name, description?, ... }Each time a new tool result arrivesLets the widget know which tool is currently associated

Integration Steps โ€‹

  1. Register a tool on the server: Add _meta.ui.resourceUri = 'ui://<your-server>/<id>' to the tool definition
  2. Register a resource handler on the server: Return mimeType: 'text/html;profile=mcp-app', with the HTML in the text field
  3. Implement the tool on the server: Include _meta.ui.resourceUri in the CallToolResult and put data the widget needs in structuredContent
  4. HTML template: Use color-scheme: light dark + light-dark() for fallback in the head; in the script, new App({ autoResize: true }).connect(), attach app.onhostcontextchanged for theme handling and app.ontoolresult for tool result handling
  5. Register in mcp.json: Local stdio or HTTP
  6. Integration testing: Call the tool in the Web UI and check the widget rendering

Security Model โ€‹

Sandbox Isolation โ€‹

  • Third-party HTML runs in a cross-origin sandbox iframe (host-provided sandbox_proxy.html, with a different origin from the main page)
  • iframe sandbox attribute: allow-scripts allow-same-origin allow-forms (the host can adjust via the resource's _meta.ui.permissions)
  • The resource's _meta.ui.csp is injected into the iframe's <meta http-equiv="Content-Security-Policy">

Authorization Mechanism โ€‹

Reverse Tool Calls (tools/call) โ€‹

Reverse tools/call requires popup authorization by default, but two short-circuit paths can skip the popup:

  1. -y / BypassPermissions startup mode: The user explicitly declared "skip all permissions" at startup; all tools (including MCP Apps reverse calls) are automatically allowed.
  2. Session-level "Always allow" cache: If "Always allow" was selected in a previous reverse call popup, subsequent calls to the same (server, tool) within the current session pass through directly; this is invalidated by /clear or restart.

Default / AcceptEdits / Plan modes still enforce the popup โ€” the user hasn't explicitly declared blanket permission, so third-party widget reverse calls must be confirmed in person. When authorization is denied, the widget receives { isError: true }, and the server is never actually called.

  • Reverse resources/read: Read-only, no authorization required (treated as a safe GET)
  • ui/open-link: Only allows http:// / https://, preventing dangerous schemes like javascript: / data:
  • ui/download-file: Pure front-end Blob + <a download>; host backend not involved

Auto-Allowing Trusted MCP Tools (Only for Model-Initiated Calls) โ€‹

CodeBuddy's MCP tools follow a "no rule means popup" security policy by default โ€” PermissionModes like AcceptEdits that auto-allow by tool type do not cover MCP tools, because the host cannot statically determine whether a third-party MCP tool "modifies local files" or "calls a remote API / incurs charges." If you want certain trusted MCP tools to stop prompting, explicitly declare them with allow rules.

Permanent allow (in user settings):

jsonc
// ~/.codebuddy/settings.json
{
  "permissions": {
    "allow": [
      "mcp__my-todo",                 // Allow all tools from the entire server
      "mcp__github__list_issues",     // Only allow github server's list_issues
      "mcp__github__get_pr_diff"
    ],
    "deny": [
      "mcp__github__delete_repo"      // Even if the server is broadly allowed, you can precisely deny a single tool
    ]
  }
}

Matching syntax:

  • mcp__<server> โ€” Allow all tools under that server
  • mcp__<server>__<tool> โ€” Allow only that specific tool
  • deny takes priority over allow, enabling "allow the entire server, but blacklist a specific dangerous tool"

Per-process allow:

bash
codebuddy --allowed-tools "mcp__my-todo,mcp__github__list_issues" "..."

Session-level allow: Select "Always allow" in the popup โ€” this writes a session ALLOW rule, valid for the current session, invalidated by /clear or restart. For persistence, use the settings approach above.

Note: The permissions.allow / --allowed-tools rules above only apply to model-initiated MCP tool calls, not to MCP Apps widget reverse calls. Widget reverse calls go through an independent sandbox approval channel that only recognizes two short-circuits: -y / BypassPermissions startup mode, or a previous "Always allow" selection in a reverse call popup (written to the current session, invalidated after /clear). This design prevents "an allow rule configured for a model-used MCP tool from being silently leveraged by a third-party widget."

Size Limits โ€‹

  • When HTML resource exceeds 256 KB, the host does not pre-fetch โ€” it only passes the resourceUri, and the guest fetches it via onReadResource
  • This is to control ACP notification payload size

Theme Adaptation Best Practice โ€‹

@mcp-ui/client@7.1.1's AppRenderer does not accept an initial hostContext value during bridge construction, so the guest's first ui/initialize receives an empty hostContext of {}. The host subsequently pushes the theme via host-context-changed. Therefore, widgets must use a two-layer fallback:

Layer 1: CSS Fallback โ€‹

Before hostContext arrives on first render, follow the user's system theme.

css
:root {
  color-scheme: light dark;
  --bg: light-dark(#fff, #1e1e1e);
  --fg: light-dark(#1a1a1a, #e6e6e6);
  --border: light-dark(#ddd, #3a3a3a);
}

/* After JS locks data-theme, override light-dark() automatic values */
html[data-theme="light"] { color-scheme: light }
html[data-theme="dark"]  { color-scheme: dark }

body { background: var(--bg); color: var(--fg); }

The light-dark() function requires Chrome 123+ / Safari 17.5+ / Firefox 120+. See MDN: light-dark() for details.

Layer 2: JS Takeover โ€‹

After the host pushes the theme via host-context-changed, set data-theme + style.colorScheme to explicitly lock it.

javascript
function applyTheme(theme) {
  if (!theme) return;  // If not received, don't touch the DOM โ€” let CSS light-dark() handle it
  document.documentElement.setAttribute('data-theme', theme);
  document.documentElement.style.colorScheme = theme;
}

applyTheme(app.hostContext?.theme);
app.onhostcontextchanged = (ctx) => applyTheme(ctx?.theme);

Relationship with Official ext-apps โ€‹

This two-layer pattern is equivalent to the useHostStyles() React hook from @modelcontextprotocol/ext-apps (see the useHostStyles.d.ts comment: "Apply theme via color-scheme CSS property, enabling light-dark() CSS function support"). If you write your widget in React, you can call useHostStyles(app, app?.getHostContext()) directly, saving you the manual CSS + applyTheme work.

Debugging Guide โ€‹

Viewing Guest-Side Logs โ€‹

javascript
app.log({ level: 'info', logger: 'my-widget', data: { foo: 'bar' } });

The main page devtools console will print with the prefix [McpUi guest:my-widget]. level supports debug / info / notice / warning / error / critical / alert / emergency, and the host maps them to console.debug / info / warn / error.

Switching to Sandbox Console Frame โ€‹

The frame dropdown in the top-left corner of Chrome DevTools can switch to the sandbox iframe โ€” console / Sources both work, isolated from the main page devtools.

Viewing ACP Pass-Through Content โ€‹

The host writes widget metadata to the ACP tool_call_update._meta['codebuddy.ai'].toolMetaData.mcpUi:

  • DevTools โ†’ Network โ†’ find the acp request โ†’ look at SSE events in the Response
  • Inject in the main page console: window.__DEBUG_ACP__ = true (if enabled in the code)

References โ€‹

Protocol & SDK โ€‹

CodeBuddy Internal Documentation โ€‹

  • MCP Overview โ€” Overall MCP integration, transport types, configuration scopes, security approval
  • Settings Configuration โ€” mcp.json / mcpServers field descriptions

Web Standards โ€‹

Official Example Servers โ€‹

For the full list, see ext-apps/examples. Several commonly used ones are published on npm and can be started directly with npx for integration testing:

PackageRepository Example DirectoryDemo Content
@modelcontextprotocol/server-mapmap-serverCesiumJS 3D map (OpenStreetMap tiles)
@modelcontextprotocol/server-threejsthreejs-serverThree.js 3D scene
@modelcontextprotocol/server-pdfpdf-serverPDF viewer
@modelcontextprotocol/server-video-resourcevideo-resource-serverVideo playback
@modelcontextprotocol/server-budget-allocatorbudget-allocator-serverInteractive budget allocation
@modelcontextprotocol/server-shadertoyshadertoy-serverGLSL shader editor
@modelcontextprotocol/server-sheet-musicsheet-music-serverABC sheet music rendering
@modelcontextprotocol/server-wiki-explorerwiki-explorer-serverWikipedia knowledge graph
@modelcontextprotocol/server-lazy-authlazy-auth-serverLazy OAuth demo