=== NEW === - studio/ — MCPEngine Studio scaffold (Next.js monorepo, build plan) - docs/FACTORY-V2.md — Factory v2 architecture doc - docs/CALENDLY_MCP_BUILD_SUMMARY.md — Calendly MCP build report === UPDATED SERVERS === - fieldedge: Added jobs-tools, UI build script, main entry update - lightspeed: Updated main + server entry points - squarespace: Added collection-browser + page-manager apps - toast: Added main + server entry points === INFRA === - infra/command-center/state.json — Updated pipeline state - infra/command-center/FACTORY-V2.md — Factory v2 operator playbook
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-appswhen 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:
- Tool Definition with
_meta.ui.resourceUri - UI Resource registered with
text/html;profile=mcp-appMIME type - 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
- Tool - Called by LLM/host, returns data with
structuredContent - Resource - Serves bundled HTML (single file via vite-plugin-singlefile)
- Link - Tool's
_meta.ui.resourceUripoints 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,registerAppResourcefrom 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 installto 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
bunbut generated projects should usetsxfor 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.tswithregisterAppToolandregisterAppResource - Client-side app with all lifecycle handlers
vite.config.tswithvite-plugin-singlefilepackage.jsonwith all required dependencies.gitignoreexcludingnode_modules/anddist/
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()">×</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
- Module scope:
<script type="module">creates isolated scope - usewindow.fn = fnfor HTML attribute access - Re-render after changes: Store data in
window._appData, update it, call render function again - Setup handlers after render: Call
setupDragDrop()at end of render function since DOM changes - Use dataset attributes:
card.dataset.idmaps todata-id="..."in HTML - Column identification: Add
data-column-idordata-stage-idto drop zone containers - 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: trueto 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:
- Check tools/list - Does tool have
_meta.ui.resourceUri? - Check resources/list - Is resource listed with correct MIME type?
- Check resources/read - Does it return HTML content?
- Check browser console - Any JS errors in the iframe?
- Check server stderr - Is resource being fetched?
- Verify build - Is
dist/app-ui/mcp-app.htmla single file with inlined JS? - 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
- Install:
@modelcontextprotocol/ext-apps,@modelcontextprotocol/sdk,zod,vite,vite-plugin-singlefile - Server: Use
McpServer+registerAppTool+registerAppResource - Schemas: Use Zod for all tool parameters
- UI: Use
Appfrom ext-apps, set ALL handlers BEFOREconnect() - Streaming: Use
ontoolinputpartialfor large inputs,ontoolinputfor final - Styling: Apply host CSS variables, handle safe area insets
- Fullscreen: Check
availableDisplayModes, userequestDisplayMode() - Resources: Use IntersectionObserver to pause expensive ops when off-screen
- Build: Vite with singlefile plugin, exclude from tsconfig
- 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.