| 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:
- UI Resources: Predeclared HTML resources using the
ui://URI scheme - Tool-UI Linkage: Tools reference UI resources via
_meta.ui.resourceUrimetadata - 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:
["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:
# 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
'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
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
references/spec.md- Key excerpts from SEP-1865 MCP Apps specificationreferences/api-quick-reference.md- Quick API reference for common operationsreferences/css-variables.md- Complete list of standardized theming CSS variables
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:
examples/basic-server-vanillajs- Minimal vanilla JS exampleexamples/basic-server-react- React implementationexamples/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
mimeTypeis exactly"text/html;profile=mcp-app" - Check that
resourceUriin 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.connectDomainsorcsp.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
visibilityin_meta.ui: ensure it includes"model" - Verify host properly filters tools based on visibility
- Confirm tool is returned in
tools/listresponse
Theming Not Working
- Verify fallback CSS variables are defined in
:root - Check if host is providing
styles.variablesin host context - Use
applyHostStyleVariablesutility 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:
Resource metadata structure changed:
- Old:
_meta["ui/resourceUri"] - New:
_meta.ui.resourceUri
- Old:
Handshake protocol changed:
- Old:
iframe-readycustom event - New:
ui/initialize→ui/notifications/initialized(MCP-like)
- Old:
Tool visibility control:
- New:
_meta.ui.visibilityarray
- New:
CSP configuration:
- Moved from tool metadata to resource metadata
- Separate
connectDomainsandresourceDomains
Import paths:
- New:
@modelcontextprotocol/ext-apps(not MCP-UI SDK)
- New:
Limitations & Future Extensions
Current MVP limitations:
- Only
text/html;profile=mcp-appcontent 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/uicapability - 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