Awesome-omni-skill mcp-mcp-apps-kit
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.
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/tools/mcp-mcp-apps-kit" ~/.claude/skills/diegosouzapw-awesome-omni-skill-mcp-mcp-apps-kit && rm -rf "$T"
skills/tools/mcp-mcp-apps-kit/SKILL.mdMCP 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:
- UI Resources: Predeclared HTML resources using the
URI schemeui:// - Tool-UI Linkage: Tools reference UI resources via
metadata_meta.ui.resourceUri - Bidirectional Communication: UI iframes communicate with hosts using JSON-RPC over postMessage
- Security Model: Mandatory iframe sandboxing with Content Security Policy enforcement
Key Pattern: Tool + UI Resource
MCP Apps follow a two-part registration pattern:
// 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:
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:
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:
(default): Tool visible to agent and callable by app["model", "app"]
: Hidden from agent, only callable by app (for UI-only interactions like refresh buttons)["app"]
: Visible to agent only, not callable by app["model"]
Step 3: Build the UI
Project setup:
# Install dependencies npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk npm install -D vite vite-plugin-singlefile typescript
Vite configuration (bundle to single HTML):
// 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:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>My MCP App</title> </head> <body> <div id="app">Loading...</div> <script type="module" src="/src/app.ts"></script> </body> </html>
App initialization (Vanilla JS/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:
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 ( <div style={{ backgroundColor: `var(--color-background-primary)`, color: `var(--color-text-primary)` }}> <h1>Data Viewer</h1> <pre>{JSON.stringify(toolResult?.structuredContent, null, 2)}</pre> <button onClick={handleRefresh}>Refresh</button> </div> ); }
Step 4: Apply Host Theming
Use standardized CSS variables:
: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:
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:
import { useHostStyleVariables, useDocumentTheme } from "@modelcontextprotocol/ext-apps/react"; function MyApp() { useHostStyleVariables(); // Automatically applies CSS variables useDocumentTheme(); // Automatically applies theme class return <div>Content styled by host</div>; }
Step 5: Implement Security
Declare CSP requirements:
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
and minimize'unsafe-eval''unsafe-inline' - Test your app with restrictive CSP during development
- Never transmit sensitive credentials through postMessage
Step 6: Handle Lifecycle Events
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:
// 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:
// Add message to conversation await app.sendMessage({ role: "user", content: { type: "text", text: "User clicked on item #123" } });
Send notifications (logs):
// Log to host console await app.sendLog({ level: "info", data: "Data refreshed successfully" });
Open external links:
// Open URL in user's browser await app.sendOpenLink({ url: "https://example.com/details/123" });
Request display mode changes:
// Request fullscreen mode const result = await app.requestDisplayMode("fullscreen"); console.log("New display mode:", result.mode);
Step 8: Test Your App
Build the UI:
npm run build
Start your MCP server:
node server.js # or npm run serve
Test with basic-host (from ext-apps repo):
# 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:
- Configure your server in Claude Desktop's MCP settings
- Call your tool from the chat
- Verify the UI renders correctly
- Test interactions (buttons, forms, etc.)
- 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:
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:
app.ontoolinputpartial = (partial) => { // Update UI with partial progress updateProgress(partial); };
Multi-Page Apps
Create multi-screen experiences by registering multiple UI resources:
// 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:
// 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:
// 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:
// 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
- Key excerpts from SEP-1865 MCP Apps specificationreferences/spec.md
- Quick API reference for common operationsreferences/api-quick-reference.md
- Complete list of standardized theming CSS variablesreferences/css-variables.md
Official Documentation
- MCP Apps Repository - Official SDK and examples
- API Documentation - Complete API reference
- Quickstart Guide - Step-by-step tutorial
- Draft Specification - Full SEP-1865 spec
Examples
See the official repository's examples directory:
- Minimal vanilla JS exampleexamples/basic-server-vanillajs
- React implementationexamples/basic-server-react
- Test host for developmentexamples/basic-host
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
is exactlymimeType"text/html;profile=mcp-app" - Check that
in tool metadata matches registered resource URIresourceUri - 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
orcsp.connectDomainscsp.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
invisibility
: ensure it includes_meta.ui"model" - Verify host properly filters tools based on visibility
- Confirm tool is returned in
responsetools/list
Theming Not Working
- Verify fallback CSS variables are defined in
:root - Check if host is providing
in host contextstyles.variables - Use
utility correctlyapplyHostStyleVariables - Test with both light and dark themes
Communication Errors
- Ensure
is called before any operationsapp.connect() - 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:
-
Resource metadata structure changed:
- Old:
_meta["ui/resourceUri"] - New:
_meta.ui.resourceUri
- Old:
-
Handshake protocol changed:
- Old:
custom eventiframe-ready - New:
→ui/initialize
(MCP-like)ui/notifications/initialized
- Old:
-
Tool visibility control:
- New:
array_meta.ui.visibility
- New:
-
CSP configuration:
- Moved from tool metadata to resource metadata
- Separate
andconnectDomainsresourceDomains
-
Import paths:
- New:
(not MCP-UI SDK)@modelcontextprotocol/ext-apps
- New:
Limitations & Future Extensions
Current MVP limitations:
- Only
content type supportedtext/html;profile=mcp-app - 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
capabilityio.modelcontextprotocol/ui - 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