--- name: mcp-mcp-apps-kit description: Guide for implementing MCP Apps (SEP-1865) - interactive UI extensions for MCP servers. Use when building MCP servers that need to return rich, interactive HTML-based UIs alongside tool results for conversational AI hosts like Claude Desktop or ChatGPT. --- # MCP Apps Builder ## Overview This skill provides comprehensive guidance for implementing **MCP Apps** - an extension to the Model Context Protocol (SEP-1865) that enables MCP servers to deliver interactive user interfaces to conversational AI hosts. **Use this skill when:** - Building MCP servers that need to return rich, interactive UIs alongside tool results - Adding visual data presentation capabilities to existing MCP tools - Creating interactive dashboards, forms, or visualizations within MCP-enabled clients - Implementing bidirectional communication between UI components and MCP servers - Migrating from MCP-UI or building Apps SDK-compatible MCP servers ## Core Concepts ### What are MCP Apps? MCP Apps extend the Model Context Protocol with: 1. **UI Resources**: Predeclared HTML resources using the `ui://` URI scheme 2. **Tool-UI Linkage**: Tools reference UI resources via `_meta.ui.resourceUri` metadata 3. **Bidirectional Communication**: UI iframes communicate with hosts using JSON-RPC over postMessage 4. **Security Model**: Mandatory iframe sandboxing with Content Security Policy enforcement ### Key Pattern: Tool + UI Resource MCP Apps follow a two-part registration pattern: ```typescript // 1. Register the UI resource server.registerResource({ uri: "ui://my-server/dashboard", name: "Dashboard", mimeType: "text/html;profile=mcp-app", // HTML content returned via resources/read }); // 2. Register a tool that references the UI server.registerTool("get_data", { description: "Get data with interactive visualization", inputSchema: { /* ... */ }, _meta: { ui: { resourceUri: "ui://my-server/dashboard" } } }); ``` ## Implementation Workflow Follow these steps in order to build an MCP App from scratch. ### Step 1: Design Your App **Identify the use case:** - What data does your tool return? - How should that data be visualized? - What user interactions are needed? - Does the UI need to call back to the server? **Plan the architecture:** - Determine tool structure (inputs, outputs) - Design UI layout and interactions - Identify required external resources (APIs, CDNs) - Plan CSP requirements for security ### Step 2: Implement the MCP Server **Register UI resources:** ```typescript const server = new McpServer({ name: "my-app-server", version: "1.0.0" }); // Register HTML resource server.registerResource({ uri: "ui://my-server/widget", name: "Interactive Widget", description: "Widget for displaying data", mimeType: "text/html;profile=mcp-app", _meta: { ui: { csp: { connectDomains: ["https://api.example.com"], resourceDomains: ["https://cdn.jsdelivr.net"] }, prefersBorder: true } } }); // Handle resource reads server.setResourceHandler(async (uri) => { if (uri === "ui://my-server/widget") { const html = await fs.readFile("dist/widget.html", "utf-8"); return { contents: [{ uri, mimeType: "text/html;profile=mcp-app", text: html }] }; } }); ``` **Link tools to UI resources:** ```typescript server.registerTool("fetch_data", { title: "Fetch Data", description: "Fetches data and displays it interactively", inputSchema: { type: "object", properties: { query: { type: "string" } } }, outputSchema: { /* ... */ }, _meta: { ui: { resourceUri: "ui://my-server/widget", visibility: ["model", "app"] // Default: visible to both } } }, async (args) => { const data = await fetchData(args.query); return { content: [ { type: "text", text: `Found ${data.length} results` } ], structuredContent: data, // UI-optimized data _meta: { timestamp: new Date().toISOString() } }; }); ``` **Tool visibility options:** - `["model", "app"]` (default): Tool visible to agent and callable by app - `["app"]`: Hidden from agent, only callable by app (for UI-only interactions like refresh buttons) - `["model"]`: Visible to agent only, not callable by app ### Step 3: Build the UI **Project setup:** ```bash # Install dependencies npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk npm install -D vite vite-plugin-singlefile typescript ``` **Vite configuration (bundle to single HTML):** ```typescript // vite.config.ts import { defineConfig } from "vite"; import { viteSingleFile } from "vite-plugin-singlefile"; export default defineConfig({ plugins: [viteSingleFile()], build: { outDir: "dist", rollupOptions: { input: process.env.INPUT || "app.html" } } }); ``` **HTML structure:** ```html My MCP App
Loading...
``` **App initialization (Vanilla JS/TypeScript):** ```typescript import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps"; const app = new App({ name: "My MCP App", version: "1.0.0" }); // Register handlers BEFORE connecting app.ontoolresult = (result) => { const data = result.structuredContent; renderData(data); }; app.onhostcontextchange = (context) => { // Handle theme changes if (context.theme) { applyTheme(context.theme); } }; // Connect to host await app.connect(new PostMessageTransport(window.parent)); // Now you can interact with the server document.getElementById("refresh-btn")?.addEventListener("click", async () => { const result = await app.callServerTool({ name: "fetch_data", arguments: { query: "latest" } }); renderData(result.structuredContent); }); ``` **React version:** ```typescript import { useApp, useToolResult, useHostContext } from "@modelcontextprotocol/ext-apps/react"; function MyApp() { const app = useApp({ name: "My MCP App", version: "1.0.0" }); const toolResult = useToolResult(); const hostContext = useHostContext(); const handleRefresh = async () => { await app.callServerTool({ name: "fetch_data", arguments: { query: "latest" } }); }; return (

Data Viewer

{JSON.stringify(toolResult?.structuredContent, null, 2)}
); } ``` ### Step 4: Apply Host Theming **Use standardized CSS variables:** ```css :root { /* Fallback defaults for graceful degradation */ --color-background-primary: light-dark(#ffffff, #171717); --color-text-primary: light-dark(#171717, #fafafa); --font-sans: system-ui, -apple-system, sans-serif; --border-radius-md: 8px; } .container { background: var(--color-background-primary); color: var(--color-text-primary); font-family: var(--font-sans); border-radius: var(--border-radius-md); } ``` See `references/css-variables.md` for the complete list of standardized CSS variables. **Apply host-provided styles:** ```typescript import { applyHostStyleVariables, applyDocumentTheme } from "@modelcontextprotocol/ext-apps"; app.onhostcontextchange = (context) => { // Apply CSS variables from host if (context.styles?.variables) { applyHostStyleVariables(context.styles.variables); } // Apply theme class (light/dark) if (context.theme) { applyDocumentTheme(context.theme); } // Apply custom fonts if (context.styles?.css?.fonts) { const style = document.createElement("style"); style.textContent = context.styles.css.fonts; document.head.appendChild(style); } }; ``` **React hooks:** ```typescript import { useHostStyleVariables, useDocumentTheme } from "@modelcontextprotocol/ext-apps/react"; function MyApp() { useHostStyleVariables(); // Automatically applies CSS variables useDocumentTheme(); // Automatically applies theme class return
Content styled by host
; } ``` ### Step 5: Implement Security **Declare CSP requirements:** ```typescript server.registerResource({ uri: "ui://my-server/widget", name: "Widget", mimeType: "text/html;profile=mcp-app", _meta: { ui: { csp: { // Domains for fetch/XHR/WebSocket connectDomains: [ "https://api.example.com", "wss://realtime.example.com" ], // Domains for images, scripts, stylesheets, fonts resourceDomains: [ "https://cdn.jsdelivr.net", "https://*.cloudflare.com" ] }, // Optional: dedicated domain for this widget domain: "https://widget.example.com", // Request visible border/background prefersBorder: true } } }); ``` **Security best practices:** - Always declare all external domains in CSP - Use HTTPS for all external resources - Avoid `'unsafe-eval'` and minimize `'unsafe-inline'` - Test your app with restrictive CSP during development - Never transmit sensitive credentials through postMessage ### Step 6: Handle Lifecycle Events ```typescript const app = new App({ name: "My App", version: "1.0.0" }); // Initialize lifecycle app.oninitialized = (result) => { console.log("Connected to host:", result.hostInfo); console.log("Available display modes:", result.hostContext.availableDisplayModes); }; // Tool execution lifecycle app.ontoolinput = (input) => { console.log("Tool called with:", input); showLoadingState(); }; app.ontoolresult = (result) => { console.log("Tool result:", result); hideLoadingState(); renderData(result.structuredContent); }; app.ontoolcancelled = (reason) => { console.warn("Tool cancelled:", reason); hideLoadingState(); }; // Host context changes app.onhostcontextchange = (context) => { if (context.theme) applyTheme(context.theme); if (context.viewport) handleResize(context.viewport); }; // Cleanup app.onteardown = (reason) => { console.log("Tearing down:", reason); cleanupResources(); }; await app.connect(new PostMessageTransport(window.parent)); ``` ### Step 7: Add Interactive Features **Call server tools from UI:** ```typescript // Call tools from button clicks, forms, etc. async function handleAction() { try { const result = await app.callServerTool({ name: "refresh_data", arguments: { filter: "active" } }); updateUI(result.structuredContent); } catch (error) { showError(error.message); } } ``` **Send messages to chat:** ```typescript // Add message to conversation await app.sendMessage({ role: "user", content: { type: "text", text: "User clicked on item #123" } }); ``` **Send notifications (logs):** ```typescript // Log to host console await app.sendLog({ level: "info", data: "Data refreshed successfully" }); ``` **Open external links:** ```typescript // Open URL in user's browser await app.sendOpenLink({ url: "https://example.com/details/123" }); ``` **Request display mode changes:** ```typescript // Request fullscreen mode const result = await app.requestDisplayMode("fullscreen"); console.log("New display mode:", result.mode); ``` ### Step 8: Test Your App **Build the UI:** ```bash npm run build ``` **Start your MCP server:** ```bash node server.js # or npm run serve ``` **Test with basic-host (from ext-apps repo):** ```bash # In a separate terminal git clone https://github.com/modelcontextprotocol/ext-apps.git cd ext-apps/examples/basic-host npm install npm run start # Open http://localhost:8080 # Select your tool from the dropdown # Click "Call Tool" to see the UI ``` **Test in Claude Desktop or other MCP host:** 1. Configure your server in Claude Desktop's MCP settings 2. Call your tool from the chat 3. Verify the UI renders correctly 4. Test interactions (buttons, forms, etc.) 5. Verify theming matches the host ## Advanced Patterns ### App-Only Tools (Hidden from Agent) Create tools that are only callable by your UI, not by the agent: ```typescript server.registerTool("ui_refresh", { description: "Refresh UI data (internal)", inputSchema: { type: "object" }, _meta: { ui: { visibility: ["app"] // Hidden from agent } } }, async () => { return { content: [{ type: "text", text: "Refreshed" }], structuredContent: await fetchLatestData() }; }); ``` ### Streaming Tool Updates Receive partial updates during long-running tool execution: ```typescript app.ontoolinputpartial = (partial) => { // Update UI with partial progress updateProgress(partial); }; ``` ### Multi-Page Apps Create multi-screen experiences by registering multiple UI resources: ```typescript // Dashboard view server.registerResource({ uri: "ui://app/dashboard", name: "Dashboard", mimeType: "text/html;profile=mcp-app" }); // Detail view server.registerResource({ uri: "ui://app/details", name: "Details", mimeType: "text/html;profile=mcp-app" }); // Tools reference different views server.registerTool("show_dashboard", { _meta: { ui: { resourceUri: "ui://app/dashboard" } } }); server.registerTool("show_details", { _meta: { ui: { resourceUri: "ui://app/details" } } }); ``` ### Reading Server Resources from UI Access other MCP resources from your UI: ```typescript // UI can read resources const resource = await app.readResource({ uri: "file:///config.json" }); const config = JSON.parse(resource.contents[0].text); ``` ## Capability Negotiation **Server advertises MCP Apps support:** ```typescript // Server initialization const server = new McpServer({ name: "my-server", version: "1.0.0", capabilities: { extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] } } } }); ``` **Check if host supports MCP Apps:** ```typescript // In your tool handler const hostSupportsUI = client.capabilities?.extensions?.["io.modelcontextprotocol/ui"]; if (hostSupportsUI) { // Return UI metadata return { content: [{ type: "text", text: "Data loaded" }], _meta: { ui: { resourceUri: "ui://app/view" } } }; } else { // Fallback to text-only return { content: [{ type: "text", text: formatDataAsText(data) }] }; } ``` ## Resources ### References - `references/spec.md` - Key excerpts from SEP-1865 MCP Apps specification - `references/api-quick-reference.md` - Quick API reference for common operations - `references/css-variables.md` - Complete list of standardized theming CSS variables ### Official Documentation - [MCP Apps Repository](https://github.com/modelcontextprotocol/ext-apps) - Official SDK and examples - [API Documentation](https://modelcontextprotocol.github.io/ext-apps/api/) - Complete API reference - [Quickstart Guide](https://modelcontextprotocol.github.io/ext-apps/api/documents/Quickstart.html) - Step-by-step tutorial - [Draft Specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) - Full SEP-1865 spec ### Examples See the official repository's examples directory: - `examples/basic-server-vanillajs` - Minimal vanilla JS example - `examples/basic-server-react` - React implementation - `examples/basic-host` - Test host for development ## Best Practices ### Performance - Bundle UI into a single HTML file with Vite + vite-plugin-singlefile - Minimize external dependencies to reduce load time - Lazy-load heavy components - Cache UI resources on the host side ### Accessibility - Use semantic HTML elements - Provide ARIA labels for interactive elements - Support keyboard navigation - Test with screen readers - Respect host's font size preferences ### Responsive Design - Use host's viewport information for layout decisions - Support different display modes (inline, fullscreen, pip) - Handle safe area insets for mobile devices - Test on different screen sizes ### Security - Declare all external domains explicitly in CSP - Never store sensitive data in UI code - Validate all user inputs before sending to server - Use HTTPS for all external resources - Follow the principle of least privilege for CSP ### UX Guidelines - Provide loading states for async operations - Show clear error messages to users - Support host's theme (light/dark mode) - Use host's typography and colors via CSS variables - Provide meaningful fallbacks when features aren't available - Handle tool cancellation gracefully ## Troubleshooting ### UI Not Rendering - Verify `mimeType` is exactly `"text/html;profile=mcp-app"` - Check that `resourceUri` in tool metadata matches registered resource URI - Ensure host supports MCP Apps extension - Verify HTML is valid and well-formed - Check browser console for CSP violations ### CSP Errors - Declare all external domains in `csp.connectDomains` or `csp.resourceDomains` - Use wildcard subdomains carefully: `https://*.example.com` - Test with strict CSP during development - Check host's console for CSP violation reports ### Tool Not Visible to Agent - Check `visibility` in `_meta.ui`: ensure it includes `"model"` - Verify host properly filters tools based on visibility - Confirm tool is returned in `tools/list` response ### Theming Not Working - Verify fallback CSS variables are defined in `:root` - Check if host is providing `styles.variables` in host context - Use `applyHostStyleVariables` utility correctly - Test with both light and dark themes ### Communication Errors - Ensure `app.connect()` is called before any operations - Verify PostMessageTransport is using `window.parent` - Check browser console for JSONRPC errors - Confirm server is responding to tool calls ## Migration from MCP-UI **Key changes:** 1. **Resource metadata structure changed:** - Old: `_meta["ui/resourceUri"]` - New: `_meta.ui.resourceUri` 2. **Handshake protocol changed:** - Old: `iframe-ready` custom event - New: `ui/initialize` → `ui/notifications/initialized` (MCP-like) 3. **Tool visibility control:** - New: `_meta.ui.visibility` array 4. **CSP configuration:** - Moved from tool metadata to resource metadata - Separate `connectDomains` and `resourceDomains` 5. **Import paths:** - New: `@modelcontextprotocol/ext-apps` (not MCP-UI SDK) ## Limitations & Future Extensions **Current MVP limitations:** - Only `text/html;profile=mcp-app` content type supported - No direct external URL embedding - No widget-to-widget communication - No state persistence between sessions - Single UI resource per tool result **Future extensions (deferred):** - External URL content type (`text/uri-list`) - Multiple UI resources per tool - State persistence APIs - Custom sandbox policies - Screenshot/preview generation - Remote DOM support ## Notes - MCP Apps is an **optional extension** (SEP-1865) to MCP - Must be explicitly negotiated via `io.modelcontextprotocol/ui` capability - Backward compatible: tools work as text-only when host doesn't support UI - Specification is in draft status; expect refinements before GA - Based on learnings from MCP-UI community and OpenAI's Apps SDK