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.
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:
MCP Apps extend the Model Context Protocol with:
ui:// URI scheme_meta.ui.resourceUri metadataMCP 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"
}
}
});
Follow these steps in order to build an MCP App from scratch.
Identify the use case:
Plan the architecture:
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 appProject 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>
);
}
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>;
}
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:
'unsafe-eval' and minimize 'unsafe-inline'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));
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);
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:
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()
};
});
Receive partial updates during long-running tool execution:
app.ontoolinputpartial = (partial) => {
// Update UI with partial progress
updateProgress(partial);
};
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" } }
});
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);
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) }]
};
}
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 variablesSee the official repository's examples directory:
examples/basic-server-vanillajs - Minimal vanilla JS exampleexamples/basic-server-react - React implementationexamples/basic-host - Test host for developmentmimeType is exactly "text/html;profile=mcp-app"resourceUri in tool metadata matches registered resource URIcsp.connectDomains or csp.resourceDomainshttps://*.example.comvisibility in _meta.ui: ensure it includes "model"tools/list response:rootstyles.variables in host contextapplyHostStyleVariables utility correctlyapp.connect() is called before any operationswindow.parentKey changes:
Resource metadata structure changed:
_meta["ui/resourceUri"]_meta.ui.resourceUriHandshake protocol changed:
iframe-ready custom eventui/initialize → ui/notifications/initialized (MCP-like)Tool visibility control:
_meta.ui.visibility arrayCSP configuration:
connectDomains and resourceDomainsImport paths:
@modelcontextprotocol/ext-apps (not MCP-UI SDK)Current MVP limitations:
text/html;profile=mcp-app content type supportedFuture extensions (deferred):
text/uri-list)io.modelcontextprotocol/ui capabilitySearch for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer