Result
Data: ${escapeHtml(data.data)}
Time: ${data.timestamp}
--- 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: "
UI not built", }, ], }; } } ); // 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) ```htmlData: ${escapeHtml(data.data)}
Time: ${data.timestamp}
`, 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 `