Claude Code Plugins

Community-maintained marketplace

Feedback
0
0

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.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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:

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