Skip to content
mcp-mcp-apps-kit logo

MCP Apps Builder

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.

SKILL.md

Full skill instructions

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:

// 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:

  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:

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 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

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

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View