39 KiB

name description
Create MCP App This skill should be used when the user asks to "create an MCP App", "add a UI to an MCP tool", "build an interactive MCP View", "scaffold an MCP App", or needs guidance on MCP Apps SDK patterns, UI-resource registration, MCP App lifecycle, host integration, streaming input, fullscreen mode, or interactive UI patterns. The definitive guide for building MCP Apps with interactive UIs.

MCP Apps Complete Guide

Purpose: The definitive guide to building MCP Apps correctly. Invoke with /mcp-apps when working on MCP Apps. This merges official SDK documentation with battle-tested patterns from real-world debugging.


What MCP Apps Actually Are

The Core Concept

MCP Apps is an official extension to the Model Context Protocol (SEP-1865) that allows MCP servers to deliver interactive HTML user interfaces that render inside AI chat windows (Claude Desktop, Goose, etc.).

Key Distinction:

  • MCP Apps = Official standard from @modelcontextprotocol/ext-apps (USE THIS)
  • MCP-UI = Older community library from @mcp-ui/server (DEPRECATED - avoid)

The Fundamental Pattern

MCP App = Tool Definition + UI Resource + Proper Response

Three things MUST be linked:

  1. Tool Definition with _meta.ui.resourceUri
  2. UI Resource registered with text/html;profile=mcp-app MIME type
  3. Tool Response that triggers the host to fetch and render the UI

How The Data Flows

1. Server starts, registers tools with _meta.ui.resourceUri
2. Server registers UI resources via resources/list
3. Host (Goose/Claude) calls tools/list, sees _meta.ui.resourceUri
4. User triggers tool call
5. Server executes tool, returns content + structuredContent
6. Host sees tool has UI, fetches resource via resources/read
7. Host renders HTML in sandboxed iframe
8. UI calls app.connect(), receives tool result via ontoolresult
9. UI renders the data

Quick Reference: The Pattern

MCP App = Tool + UI Resource + Link
  1. Tool - Called by LLM/host, returns data with structuredContent
  2. Resource - Serves bundled HTML (single file via vite-plugin-singlefile)
  3. Link - Tool's _meta.ui.resourceUri points to the resource URI
Host calls tool → Server returns result → Host fetches resource → UI receives result via ontoolresult

Quick Start Decision Tree

Framework Selection

Framework SDK Support Best For
React useApp hook provided Teams familiar with React
Vanilla JS Manual lifecycle Simple apps, no build complexity
Vue Manual lifecycle Vue teams (template available)
Svelte Manual lifecycle Svelte teams (template available)
Preact Manual lifecycle Lightweight React alternative
Solid Manual lifecycle Solid teams (template available)

Project Context

Adding to existing MCP server:

  • Import registerAppTool, registerAppResource from SDK
  • Add tool registration with _meta.ui.resourceUri
  • Add resource registration serving bundled HTML

Creating new MCP server:

  • Set up server with transport (stdio or HTTP)
  • Register tools and resources
  • Configure build system with vite-plugin-singlefile

Required Packages

npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod
npm install -D typescript vite vite-plugin-singlefile tsx

CRITICAL: Use npm install to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory.

Package Purpose REQUIRED
@modelcontextprotocol/ext-apps Official MCP Apps SDK (server + client) YES
@modelcontextprotocol/sdk Base MCP SDK with McpServer class YES
zod Schema validation for tool parameters YES
vite + vite-plugin-singlefile Bundle UI to single HTML file YES
tsx Run TypeScript server files YES

Do NOT use: @mcp-ui/server (deprecated, different API)

Note

: The SDK examples use bun but generated projects should use tsx for broader compatibility.


Project Structure

my-mcp-app/
├── package.json
├── tsconfig.json
├── src/
│   ├── server.ts              # MCP server with tools + resources
│   └── app-ui/
│       ├── mcp-app.html       # UI HTML template
│       ├── src/
│       │   └── mcp-app.ts     # UI logic
│       └── vite.config.ts     # Bundles to single file
└── dist/
    ├── server.js              # Compiled server
    └── app-ui/
        └── mcp-app.html       # Bundled single-file UI

Get Reference Code

Clone the SDK repository for working examples and API documentation:

git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 \
  https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps

Framework Templates

Learn and adapt from /tmp/mcp-ext-apps/examples/basic-server-{framework}/:

Template Key Files
basic-server-vanillajs/ server.ts, src/mcp-app.ts, mcp-app.html
basic-server-react/ server.ts, src/mcp-app.tsx (uses useApp hook)
basic-server-vue/ server.ts, src/App.vue
basic-server-svelte/ server.ts, src/App.svelte
basic-server-preact/ server.ts, src/mcp-app.tsx
basic-server-solid/ server.ts, src/mcp-app.tsx

Each template includes:

  • Complete server.ts with registerAppTool and registerAppResource
  • Client-side app with all lifecycle handlers
  • vite.config.ts with vite-plugin-singlefile
  • package.json with all required dependencies
  • .gitignore excluding node_modules/ and dist/

API Reference (Source Files)

Read JSDoc documentation directly from /tmp/mcp-ext-apps/src/:

File Contents
src/app.ts App class, handlers (ontoolinput, ontoolresult, onhostcontextchanged, onteardown), lifecycle
src/server/index.ts registerAppTool, registerAppResource, tool visibility options
src/spec.types.ts All type definitions: McpUiHostContext, CSS variable keys, display modes
src/styles.ts applyDocumentTheme, applyHostStyleVariables, applyHostFonts
src/react/useApp.tsx useApp hook for React apps
src/react/useHostStyles.ts useHostStyles, useHostStyleVariables, useHostFonts hooks

Advanced Examples

Example Pattern Demonstrated
examples/shadertoy-server/ Streaming partial input + visibility-based pause/play (best practice for large inputs)
examples/wiki-explorer-server/ callServerTool for interactive data fetching
examples/system-monitor-server/ Polling pattern with interval management
examples/video-resource-server/ Binary/blob resources
examples/sheet-music-server/ ontoolinput - processing tool args before execution completes
examples/threejs-server/ ontoolinputpartial - streaming/progressive rendering
examples/map-server/ updateModelContext - keeping model informed of UI state
examples/transcript-server/ updateModelContext + sendMessage - background context updates + user-initiated messages
examples/basic-host/ Reference host implementation using AppBridge

Server-Side Implementation

Critical Imports

import {
  registerAppTool,
  registerAppResource,
  RESOURCE_MIME_TYPE,  // = "text/html;profile=mcp-app"
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

Complete Server Template

import {
  registerAppTool,
  registerAppResource,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import * as fs from "node:fs/promises";
import * as path from "node:path";

// Path to bundled UI (after vite build)
const DIST_DIR = path.join(__dirname, "app-ui");

export function createServer(): McpServer {
  const server = new McpServer({
    name: "my-mcp-app",
    version: "1.0.0",
  });

  // CRITICAL: Define the resource URI (must use ui:// scheme)
  const widgetResourceUri = "ui://my-app/widget.html";

  // ============================================
  // STEP 1: Register Tool with UI Metadata
  // ============================================
  registerAppTool(
    server,
    "my_tool",  // Tool name (snake_case recommended)
    {
      title: "My Tool",  // Human-readable title
      description: "Description for the LLM to understand when to use this",
      inputSchema: {
        // Use Zod schemas for parameters
        param1: z.string().describe("Parameter description"),
        param2: z.number().optional().describe("Optional number"),
      },
      // CRITICAL: This links tool to UI resource
      _meta: {
        ui: { resourceUri: widgetResourceUri },
      },
    },
    async (args: { param1: string; param2?: number }) => {
      // Your tool logic here
      const result = {
        data: args.param1,
        timestamp: new Date().toISOString(),
      };

      // CRITICAL: Return format
      return {
        // Text fallback for non-UI hosts
        content: [
          {
            type: "text" as const,
            text: `Result: ${JSON.stringify(result)}`,
          },
        ],
        // Structured data passed to UI via ontoolresult
        structuredContent: result,
      };
    }
  );

  // ============================================
  // STEP 2: Register UI Resource
  // ============================================
  registerAppResource(
    server as any,  // Type cast needed due to SDK types
    widgetResourceUri,  // Resource name
    widgetResourceUri,  // Resource URI (same as name for simplicity)
    { mimeType: RESOURCE_MIME_TYPE },  // CRITICAL: Use this constant
    async () => {
      console.error(`[MCP App] Reading UI resource`);
      try {
        const html = await fs.readFile(
          path.join(DIST_DIR, "mcp-app.html"),
          "utf-8"
        );
        console.error(`[MCP App] UI loaded (${html.length} bytes)`);
        return {
          contents: [
            {
              uri: widgetResourceUri,
              mimeType: RESOURCE_MIME_TYPE,
              text: html,
            },
          ],
        };
      } catch (error) {
        console.error(`[MCP App] Failed to read UI:`, error);
        return {
          contents: [
            {
              uri: widgetResourceUri,
              mimeType: RESOURCE_MIME_TYPE,
              text: "<html><body>UI not built</body></html>",
            },
          ],
        };
      }
    }
  );

  // Add more tools (with or without UI)
  server.tool(
    "helper_tool",
    "A helper tool without UI",
    {
      input: z.string().describe("Input value"),
    },
    async (args) => {
      return {
        content: [{ type: "text" as const, text: `Got: ${args.input}` }],
      };
    }
  );

  return server;
}

// Entry point
async function main() {
  const server = createServer();
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP App Server started");
}

main().catch((e) => {
  console.error(`Error: ${e.message}`);
  process.exit(1);
});

Tool Registration (Quick Reference)

const resourceUri = "ui://my-app/widget.html";

registerAppTool(
  server,
  "tool_name",
  {
    title: "Tool Title",
    description: "Description for LLM",
    inputSchema: {
      param: z.string().describe("Parameter description"),
    },
    _meta: {
      ui: { resourceUri },
    },
  },
  async (args) => {
    const result = { data: args.param };
    return {
      content: [{ type: "text" as const, text: JSON.stringify(result) }],
      structuredContent: result,  // This goes to UI
    };
  }
);

Resource Registration

registerAppResource(
  server as any,
  resourceUri,
  resourceUri,
  { mimeType: RESOURCE_MIME_TYPE },
  async () => ({
    contents: [{
      uri: resourceUri,
      mimeType: RESOURCE_MIME_TYPE,
      text: await fs.readFile(path.join(__dirname, "app-ui/mcp-app.html"), "utf-8"),
    }],
  })
);

Tool Visibility Options

// Default: visible to both model and app
_meta: { ui: { resourceUri, visibility: ["model", "app"] } }

// UI-only (hidden from model) - for refresh buttons, form submissions, internal actions
_meta: { ui: { resourceUri, visibility: ["app"] } }

// Model-only (app cannot call)
_meta: { ui: { resourceUri, visibility: ["model"] } }

Server-Side Action Tools

For interactive UIs, add action tools that the UI calls via callServerTool:

// View tool (returns UI) - called by model
registerAppTool(server, "view_board", { /* ... */ }, async (args) => {
  const data = await getData();
  return {
    content: [{ type: "text", text: `Board loaded` }],
    structuredContent: data
  };
});

// Action tool (no UI) - called by UI via callServerTool
{
  name: 'update_item',
  description: 'Update an item (used by UI for drag-drop/edit)',
  inputSchema: {
    type: 'object',
    properties: {
      itemId: { type: 'string' },
      columnId: { type: 'string' },
      name: { type: 'string' },
      value: { type: 'number' }
    },
    required: ['itemId']
  }
  // No _meta.ui - this is not a view tool
}

// Handler in executeTool:
case 'update_item':
  const result = await apiClient.updateItem(args.itemId, args);
  return {
    content: [{ type: 'text', text: `Updated ${result.name}` }],
    structuredContent: { success: true, item: result }
  };

Client-Side Implementation

Complete HTML Template (src/app-ui/mcp-app.html)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>My MCP App</title>
  <style>
    /* All styles inline - will be bundled */
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      padding: 16px;
      background: #f5f5f5;
    }
    .loading { text-align: center; color: #666; padding: 20px; }
    .error { color: #dc2626; background: #fef2f2; padding: 16px; border-radius: 8px; }
    .content { background: white; padding: 16px; border-radius: 8px; }
  </style>
</head>
<body>
  <div id="app">
    <div class="loading">Loading...</div>
  </div>
  <script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>

Complete UI Logic (src/app-ui/src/mcp-app.ts)

import { App } from "@modelcontextprotocol/ext-apps";

// Define your data types
interface MyData {
  data: string;
  timestamp: string;
}

const appEl = document.getElementById("app")!;

// Create app instance
const app = new App({
  name: "My MCP App",
  version: "1.0.0"
});

// ============================================
// CRITICAL: Set handlers BEFORE connect()
// ============================================
app.ontoolresult = (result) => {
  console.log("Tool result received:", result);

  try {
    // Get data from structuredContent (preferred) or parse text
    let data: MyData;

    if (result.structuredContent) {
      data = result.structuredContent as MyData;
    } else {
      const textContent = result.content?.find((c) => c.type === "text")?.text;
      if (textContent) {
        data = JSON.parse(textContent);
      } else {
        throw new Error("No data in result");
      }
    }

    renderContent(data);
  } catch (error) {
    console.error("Failed to parse result:", error);
    appEl.innerHTML = `<div class="error">Failed to load: ${error}</div>`;
  }
};

app.onerror = (error) => {
  console.error("App error:", error);
  appEl.innerHTML = `<div class="error">Error: ${error.message}</div>`;
};

// ============================================
// CRITICAL: Connect to host (must be called)
// ============================================
app.connect();

// Render function
function renderContent(data: MyData) {
  appEl.innerHTML = `
    <div class="content">
      <h2>Result</h2>
      <p><strong>Data:</strong> ${escapeHtml(data.data)}</p>
      <p><strong>Time:</strong> ${data.timestamp}</p>
      <button id="refresh">Refresh</button>
    </div>
  `;

  // Add interactivity
  document.getElementById("refresh")?.addEventListener("click", async () => {
    try {
      const result = await app.callServerTool({
        name: "my_tool",
        arguments: { param1: "refreshed" }
      });
      const newData = result.structuredContent as MyData;
      renderContent(newData);
    } catch (error) {
      console.error("Refresh failed:", error);
    }
  });
}

// Helper to prevent XSS
function escapeHtml(text: string): string {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

Vanilla JS Pattern (Full Lifecycle)

import { App } from "@modelcontextprotocol/ext-apps";
import { applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";

const app = new App({ name: "My App", version: "1.0.0" });

// CRITICAL: Set ALL handlers BEFORE connect()
app.ontoolinput = (params) => {
  // Tool args available immediately (before execution completes)
  console.log("Input:", params.arguments);
};

app.ontoolinputpartial = (params) => {
  // Streaming partial input (healed JSON, always valid)
  console.log("Partial:", params.arguments);
};

app.ontoolresult = (result) => {
  // Tool execution complete - render the data
  const data = result.structuredContent;
  renderUI(data);
};

app.onhostcontextchanged = (ctx) => {
  // Theme/style updates from host
  if (ctx.theme) applyDocumentTheme(ctx.theme);
  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
  if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
  // Handle safe area insets
  if (ctx.safeAreaInsets) {
    const { top, right, bottom, left } = ctx.safeAreaInsets;
    document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
  }
};

app.onteardown = async () => {
  // Cleanup before UI closes
  return {};
};

app.onerror = (error) => {
  console.error("App error:", error);
};

// THEN connect
await app.connect();

React Pattern

import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";

function MyApp() {
  const [data, setData] = useState(null);

  const { app } = useApp({
    appInfo: { name: "My App", version: "1.0.0" },
    onToolResult: (result) => setData(result.structuredContent),
  });

  useHostStyles(app);  // Injects CSS variables, making var(--*) available

  return <div>{data && <Content data={data} />}</div>;
}

Calling Server Tools from UI

const result = await app.callServerTool({
  name: "tool_name",
  arguments: { param: "value" }
});
const newData = result.structuredContent;

Updating Model Context

// Keep the model informed of UI state changes
app.updateModelContext({
  summary: "User selected 3 items",
  details: { selectedIds: [1, 2, 3] }
});

Debug Logging to Host

// Send debug logs to the host application (not just iframe dev console)
await app.sendLog({ level: "info", data: "Debug message" });
await app.sendLog({ level: "error", data: { error: err.message } });

App Lifecycle Handlers

Handler When It Fires Use For
ontoolinput Tool args available (before execution) Show loading state, preview
ontoolinputpartial Streaming partial input (healed JSON) Progressive rendering during generation
ontoolresult Tool execution complete Render main UI
onhostcontextchanged Theme/style/locale/display mode changes Apply host styling, safe areas, fullscreen
onteardown UI closing Cleanup, save state
onerror Error occurred Error display

Streaming Partial Input

For large tool inputs, use ontoolinputpartial to show progress during LLM generation. The partial JSON is healed (always valid), enabling progressive UI updates.

Spec: ui/notifications/tool-input-partial

app.ontoolinputpartial = (params) => {
  const args = params.arguments; // Healed partial JSON - always valid, fields appear as generated
  // Use args directly for progressive rendering
};

app.ontoolinput = (params) => {
  // Final complete input - switch from preview to full render
};

Streaming Use Cases

Pattern Example
Code preview Show streaming code in <pre>, render on complete (examples/shadertoy-server/)
Progressive form Fill form fields as they stream in
Live chart Add data points to chart as array grows
Partial render Render incomplete structured data (tables, lists, trees)

Simple Streaming Pattern (Code Preview)

app.ontoolinputpartial = (params) => {
  codePreview.textContent = params.arguments?.code ?? "";
  codePreview.style.display = "block";
  canvas.style.display = "none";
};
app.ontoolinput = (params) => {
  codePreview.style.display = "none";
  canvas.style.display = "block";
  render(params.arguments);
};

Visibility-Based Resource Management

Pause expensive operations (animations, WebGL, polling) when view scrolls out of viewport:

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      animation.play(); // or: startPolling(), shaderToy.play()
    } else {
      animation.pause(); // or: stopPolling(), shaderToy.pause()
    }
  });
});
observer.observe(document.querySelector(".main"));

See examples/shadertoy-server/ for a real implementation combining streaming input + visibility pause.


Fullscreen Mode

Request fullscreen via app.requestDisplayMode(). Check availability in host context:

let currentMode: "inline" | "fullscreen" = "inline";

app.onhostcontextchanged = (ctx) => {
  // Check if fullscreen available
  if (ctx.availableDisplayModes?.includes("fullscreen")) {
    fullscreenBtn.style.display = "block";
  }
  // Track current mode
  if (ctx.displayMode) {
    currentMode = ctx.displayMode;
    container.classList.toggle("fullscreen", currentMode === "fullscreen");
  }
};

async function toggleFullscreen() {
  const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen";
  const result = await app.requestDisplayMode({ mode: newMode });
  currentMode = result.mode;
}

CSS for Fullscreen — Remove border radius in fullscreen:

.main { border-radius: var(--border-radius-lg); overflow: hidden; }
.main.fullscreen { border-radius: 0; }

See examples/shadertoy-server/ for complete implementation.


Safe Area Insets

Always respect safeAreaInsets to avoid content being clipped by host chrome:

app.onhostcontextchanged = (ctx) => {
  if (ctx.safeAreaInsets) {
    const { top, right, bottom, left } = ctx.safeAreaInsets;
    document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
  }
};

Host CSS Variables

Applying Styles

Vanilla JS - Use helper functions:

import { applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";

app.onhostcontextchanged = (ctx) => {
  if (ctx.theme) applyDocumentTheme(ctx.theme);
  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
  if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
};

React - Use hooks:

import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";

const { app } = useApp({ appInfo, capabilities, onAppCreated });
useHostStyles(app); // Injects CSS variables to document, making var(--*) available

Using Variables in CSS

After applying with applyHostStyleVariables() or useHostStyles():

.container {
  background: var(--color-background-secondary);
  color: var(--color-text-primary);
  font-family: var(--font-sans);
  border-radius: var(--border-radius-md);
}
.code {
  font-family: var(--font-mono);
  font-size: var(--font-text-sm-size);
  line-height: var(--font-text-sm-line-height);
  color: var(--color-text-secondary);
}
.heading {
  font-size: var(--font-heading-lg-size);
  font-weight: var(--font-weight-semibold);
}

Key variable groups: --color-background-*, --color-text-*, --color-border-*, --font-sans, --font-mono, --font-text-*-size, --font-heading-*-size, --border-radius-*. See src/spec.types.ts for full list.


Build Configuration

Vite Config (src/app-ui/vite.config.ts)

import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [viteSingleFile()],  // CRITICAL: Bundles everything into one HTML
  root: __dirname,
  build: {
    outDir: "../../dist/app-ui",
    emptyOutDir: true,
    rollupOptions: { input: "mcp-app.html" },
  },
});

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "src/app-ui/**/*"]
}

CRITICAL: Exclude src/app-ui/**/* from TypeScript - Vite handles that separately.

package.json Scripts

{
  "type": "module",
  "scripts": {
    "build:ui": "cd src/app-ui && npx vite build",
    "build:server": "tsc",
    "build": "npm run build:ui && npm run build:server",
    "serve": "tsx src/server.ts",
    "start": "node dist/server.js"
  }
}

Interactive UI Patterns

CRITICAL: Module Scope Issue

When using <script type="module">, all variables are module-scoped and NOT accessible from:

  • External <script> tags
  • HTML attributes (onclick, onsubmit, ondblclick)

Solution 1: Keep everything in the module

// Inside the module script - works because app and functions are in scope
function renderUI(data) {
  // Render UI
  renderHTML(data);
  // Store data globally for interactive features
  window._appData = data;
  // Setup interactivity after render
  setupInteractivity();
}

function setupInteractivity() {
  document.querySelectorAll('.card').forEach(card => {
    card.draggable = true;
    card.ondragstart = (e) => { /* has access to module's app variable */ };
    card.ondblclick = () => openEditModal(card.dataset.id);
  });
}

Solution 2: Expose to window (for HTML attributes)

// At end of module, expose functions needed by HTML onclick/onsubmit
window.openEditModal = function(id) { /* ... */ };
window.closeEditModal = function() { /* ... */ };
window.saveItem = async function(e) {
  e.preventDefault();
  await app.callServerTool({ name: 'update_item', arguments: { id, data } });
};

Drag-and-Drop Pattern

// Store current data globally for access during drag/drop
window._appData = null;

function setupDragDrop() {
  let draggedId = null;

  // Make cards draggable
  document.querySelectorAll('.card').forEach(card => {
    card.draggable = true;

    card.ondragstart = (e) => {
      draggedId = card.dataset.id;
      card.classList.add('dragging');
      e.dataTransfer.effectAllowed = 'move';
    };

    card.ondragend = () => {
      card.classList.remove('dragging');
      document.querySelectorAll('.column').forEach(c => c.classList.remove('drag-over'));
    };
  });

  // Setup drop zones
  document.querySelectorAll('.column').forEach(col => {
    col.ondragover = (e) => {
      e.preventDefault();
      col.classList.add('drag-over');
    };

    col.ondragleave = (e) => {
      if (!col.contains(e.relatedTarget)) col.classList.remove('drag-over');
    };

    col.ondrop = async (e) => {
      e.preventDefault();
      col.classList.remove('drag-over');
      if (!draggedId) return;

      const newColumnId = col.dataset.columnId;

      // Call server to persist the move
      try {
        await app.callServerTool({
          name: 'update_item',
          arguments: { itemId: draggedId, columnId: newColumnId }
        });

        // Update local data and re-render
        const item = window._appData.items.find(i => i.id === draggedId);
        if (item) {
          item.columnId = newColumnId;
          renderUI(window._appData);  // Re-render (which calls setupDragDrop again)
        }
      } catch (err) {
        alert('Move failed: ' + err.message);
      }

      draggedId = null;
    };
  });
}

Edit Modal Pattern

HTML Structure:

<div id="editModal" class="modal-overlay" style="display:none;">
  <div class="modal-content">
    <div class="modal-header">
      <h3>Edit Item</h3>
      <button onclick="closeEditModal()">&times;</button>
    </div>
    <form onsubmit="saveItem(event)">
      <input type="hidden" id="editItemId">
      <div class="form-group">
        <label>Name</label>
        <input type="text" id="editName" required>
      </div>
      <div class="form-group">
        <label>Value</label>
        <input type="number" id="editValue">
      </div>
      <button type="submit">Save</button>
    </form>
  </div>
</div>

JavaScript (in module, exposed to window):

window.openEditModal = function(id) {
  const item = window._appData?.items?.find(i => i.id === id);
  if (!item) return;

  document.getElementById('editItemId').value = id;
  document.getElementById('editName').value = item.name || '';
  document.getElementById('editValue').value = item.value || 0;
  document.getElementById('editModal').style.display = 'flex';
};

window.closeEditModal = function() {
  document.getElementById('editModal').style.display = 'none';
};

window.saveItem = async function(e) {
  e.preventDefault();
  const id = document.getElementById('editItemId').value;
  const name = document.getElementById('editName').value;
  const value = parseFloat(document.getElementById('editValue').value) || 0;

  try {
    await app.callServerTool({
      name: 'update_item',
      arguments: { itemId: id, name, value }
    });

    // Update local data and re-render
    const item = window._appData?.items?.find(i => i.id === id);
    if (item) {
      item.name = name;
      item.value = value;
      renderUI(window._appData);
    }
    closeEditModal();
  } catch (err) {
    alert('Save failed: ' + err.message);
  }
};

CSS for Drag-and-Drop & Modals

.card {
  cursor: grab;
  transition: transform 0.2s, opacity 0.2s;
}

.card:active {
  cursor: grabbing;
}

.card.dragging {
  opacity: 0.5;
  transform: rotate(2deg);
}

.column.drag-over .cards-container {
  background: #e0e7ff;
  border: 2px dashed #4f46e5;
}

.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-content {
  background: white;
  padding: 24px;
  border-radius: 8px;
  min-width: 300px;
}

Key Lessons Learned

  1. Module scope: <script type="module"> creates isolated scope - use window.fn = fn for HTML attribute access
  2. Re-render after changes: Store data in window._appData, update it, call render function again
  3. Setup handlers after render: Call setupDragDrop() at end of render function since DOM changes
  4. Use dataset attributes: card.dataset.id maps to data-id="..." in HTML
  5. Column identification: Add data-column-id or data-stage-id to drop zone containers
  6. Action vs View tools: View tools have _meta.ui.resourceUri, action tools don't

Data Handling Gotchas

Array Data Must Be Validated

// BAD - crashes if data.items isn't an array
const html = data.items.map(item => `<li>${item}</li>`).join('');

// GOOD - defensive coding
let items: Item[] = [];
if (Array.isArray(data.items)) {
  items = data.items;
} else if (data.items && typeof data.items === 'object') {
  // Handle nested { items: [...] } format
  const nested = (data.items as any).items;
  if (Array.isArray(nested)) items = nested;
}
const html = items.map(item => `<li>${item}</li>`).join('');

Always Escape User Content

function escapeHtml(text: string): string {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

Critical Rules

MUST DO

Requirement Why It Matters
Use McpServer class Low-level Server class doesn't work with helpers
Use registerAppTool Properly sets up _meta.ui.resourceUri in both formats
Use registerAppResource Registers resource with correct MIME type
Use RESOURCE_MIME_TYPE constant Must be exactly text/html;profile=mcp-app
Use ui:// scheme for URIs Required by spec
Bundle UI to SINGLE HTML file Host expects one file, use vite-plugin-singlefile
Set ALL handlers BEFORE app.connect() Miss initial data otherwise
Call app.connect() UI won't communicate without it
Return structuredContent in tool response This is how UI receives typed data
Return text content as fallback Non-UI hosts need this
Use Zod schemas for tool params McpServer requires Zod, not plain objects
Exclude app-ui from tsconfig Vite compiles UI separately
Handle safeAreaInsets Avoid content clipped by host chrome
Use npm install for dependencies Never hardcode version numbers from memory
Use host CSS variables Theme integration, don't hardcode colors
Use ontoolinputpartial for large inputs Show progress during LLM generation

MUST NOT

Anti-Pattern What Happens
Using Server instead of McpServer registerAppTool won't work
Using @mcp-ui/server Different API, deprecated
Plain object inputSchema TypeScript errors, runtime failures
Multiple JS/CSS files in UI Host can't load them
Setting handlers after connect() Miss the initial tool result
Forgetting _meta.ui.resourceUri Host won't fetch UI
Wrong MIME type Host ignores resource
import.meta.url in CommonJS Build errors
Returning _meta in tool response Not needed if tool definition has it
Hardcoded styles instead of CSS vars Breaks host theme integration
Ignoring safe area insets Content clipped on some hosts
Manually writing dependency versions Version mismatches, use npm install

Host-Specific Notes

Goose

  • Minimum version: 1.19.0 for MCP Apps
  • Works in: Goose Desktop, Goose Web (goose web), Goose CLI
  • Config location: ~/.config/goose/config.yaml
  • Enable alpha features: Add ALPHA_FEATURES: true to config
  • Logs: Server stderr goes to Goose logs

Claude Desktop

  • Config location: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Node path: Use full path like /opt/homebrew/opt/node@22/bin/node
  • Logs: Check Claude Desktop developer console

Testing

Quick Pipe Tests (JSONRPC)

# Test tools/list - verify _meta.ui.resourceUri present
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | node dist/server.js

# Test resources/list - verify MIME type correct
echo '{"jsonrpc":"2.0","id":1,"method":"resources/list","params":{}}' | node dist/server.js

# Test resources/read - verify HTML returned
echo '{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"ui://my-app/widget.html"}}' | node dist/server.js

Using basic-host (Interactive Testing)

Test MCP Apps locally with the basic-host example from the SDK repo:

# Terminal 1: Build and run your server
npm run build && npm run serve

# Terminal 2: Run basic-host (from cloned repo)
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# Open http://localhost:8080

Configure SERVERS with a JSON array of your server URLs (default: http://localhost:3001/mcp).


Debugging Checklist

When UI doesn't render:

  1. Check tools/list - Does tool have _meta.ui.resourceUri?
  2. Check resources/list - Is resource listed with correct MIME type?
  3. Check resources/read - Does it return HTML content?
  4. Check browser console - Any JS errors in the iframe?
  5. Check server stderr - Is resource being fetched?
  6. Verify build - Is dist/app-ui/mcp-app.html a single file with inlined JS?
  7. Use sendLog - Send debug logs to host from UI:
    await app.sendLog({ level: "info", data: "Debug message" });
    await app.sendLog({ level: "error", data: { error: err.message } });
    

Add logging to resource handler:

async () => {
  console.error(`[DEBUG] resources/read called for: ${uri}`);
  // ...
}

Common Errors

Error Cause Fix
n.map is not a function Data isn't array Validate with Array.isArray()
UI shows "Loading..." forever connect() not called Add await app.connect()
UI doesn't receive data Handlers set after connect Set handlers BEFORE connect()
TypeScript errors with inputSchema Plain objects used Use Zod schemas
Host doesn't fetch resource Tool missing _meta Use registerAppTool helper
onclick/ondblclick not firing Functions scoped in module Assign to window
MIME type mismatch Hardcoded wrong type Use RESOURCE_MIME_TYPE constant
"UI not built" error Vite didn't run Run npm run build:ui
_meta.ui.resourceUri not in tools/list Using wrong SDK Use @modelcontextprotocol/ext-apps/server
Drag-and-drop not working Event handlers not accessible Use module-internal handlers
Content clipped at edges Missing safe area handling Handle ctx.safeAreaInsets
Theme looks wrong in host Hardcoded colors Use --color-* CSS variables

Summary: The Golden Path

  1. Install: @modelcontextprotocol/ext-apps, @modelcontextprotocol/sdk, zod, vite, vite-plugin-singlefile
  2. Server: Use McpServer + registerAppTool + registerAppResource
  3. Schemas: Use Zod for all tool parameters
  4. UI: Use App from ext-apps, set ALL handlers BEFORE connect()
  5. Streaming: Use ontoolinputpartial for large inputs, ontoolinput for final
  6. Styling: Apply host CSS variables, handle safe area insets
  7. Fullscreen: Check availableDisplayModes, use requestDisplayMode()
  8. Resources: Use IntersectionObserver to pause expensive ops when off-screen
  9. Build: Vite with singlefile plugin, exclude from tsconfig
  10. Test: Verify tools/list shows _meta, resources/read returns HTML, use basic-host

Follow this exactly and MCP Apps will work. Deviate and you'll debug for hours.


This guide merges the official @modelcontextprotocol/ext-apps SDK documentation with battle-tested patterns from real-world debugging. Follow these patterns exactly.