1295 lines
39 KiB
Markdown

---
name: Create MCP App
description: 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
```bash
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:
```bash
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
```typescript
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
```typescript
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)
```typescript
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
```typescript
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
```typescript
// 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`:
```typescript
// 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)
```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)
```typescript
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)
```typescript
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
```typescript
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
```typescript
const result = await app.callServerTool({
name: "tool_name",
arguments: { param: "value" }
});
const newData = result.structuredContent;
```
### Updating Model Context
```typescript
// Keep the model informed of UI state changes
app.updateModelContext({
summary: "User selected 3 items",
details: { selectedIds: [1, 2, 3] }
});
```
### Debug Logging to Host
```typescript
// 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](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#streaming-tool-input)
```typescript
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)
```typescript
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:
```typescript
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:
```typescript
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:
```css
.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:
```typescript
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:
```typescript
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:
```typescript
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()`:
```css
.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)
```typescript
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
```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
```json
{
"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**
```javascript
// 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)**
```javascript
// 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
```javascript
// 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:**
```html
<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):**
```javascript
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
```css
.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
```typescript
// 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
```typescript
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)
```bash
# 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:
```bash
# 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:
```typescript
await app.sendLog({ level: "info", data: "Debug message" });
await app.sendLog({ level: "error", data: { error: err.message } });
```
Add logging to resource handler:
```typescript
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.*