Claude-skill-registry Create MCP App

This skill should be used when the user asks to "create an MCP App", "add a UI to an MCP tool", "build an interactive MCP View", "scaffold an MCP App", or needs guidance on MCP Apps SDK patterns, UI-resource registration, MCP App lifecycle, or host integration. Provides comprehensive guidance for building MCP Apps with interactive UIs.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/create-mcp-app" ~/.claude/skills/majiayu000-claude-skill-registry-create-mcp-app && rm -rf "$T"
manifest: skills/data/create-mcp-app/SKILL.md
source content

Create MCP App

Build interactive UIs that run inside MCP-enabled hosts like Claude Desktop. An MCP App combines an MCP tool with an HTML resource to display rich, interactive content.

Core Concept: Tool + Resource

Every MCP App requires two parts linked together:

  1. Tool - Called by the LLM/host, returns data
  2. Resource - Serves the bundled HTML UI that displays the data
  3. Link - The tool's
    _meta.ui.resourceUri
    references the resource
Host calls tool → Server returns result → Host renders resource UI → UI receives result

Quick Start Decision Tree

Framework Selection

FrameworkSDK SupportBest For
React
useApp
hook provided
Teams familiar with React
Vanilla JSManual lifecycleSimple apps, no build complexity
Vue/Svelte/Preact/SolidManual lifecycleFramework preference

Project Context

Adding to existing MCP server:

  • Import
    registerAppTool
    ,
    registerAppResource
    from SDK
  • Add tool registration with
    _meta.ui.resourceUri
  • Add resource registration serving bundled HTML

Creating new MCP server:

  • Set up server with transport (stdio or HTTP)
  • Register tools and resources
  • Configure build system with
    vite-plugin-singlefile

Getting Reference Code

Clone the SDK repository for working examples and API documentation:

git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps

Framework Templates

Learn and adapt from

/tmp/mcp-ext-apps/examples/basic-server-{framework}/
:

TemplateKey Files
basic-server-vanillajs/
server.ts
,
src/mcp-app.ts
,
mcp-app.html
basic-server-react/
server.ts
,
src/mcp-app.tsx
(uses
useApp
hook)
basic-server-vue/
server.ts
,
src/App.vue
basic-server-svelte/
server.ts
,
src/App.svelte
basic-server-preact/
server.ts
,
src/mcp-app.tsx
basic-server-solid/
server.ts
,
src/mcp-app.tsx

Each template includes:

  • Complete
    server.ts
    with
    registerAppTool
    and
    registerAppResource
  • Client-side app with all lifecycle handlers
  • vite.config.ts
    with
    vite-plugin-singlefile
  • package.json
    with all required dependencies
  • .gitignore
    excluding
    node_modules/
    and
    dist/

API Reference (Source Files)

Read JSDoc documentation directly from

/tmp/mcp-ext-apps/src/
:

FileContents
src/app.ts
App
class, handlers (
ontoolinput
,
ontoolresult
,
onhostcontextchanged
,
onteardown
), lifecycle
src/server/index.ts
registerAppTool
,
registerAppResource
, tool visibility options
src/spec.types.ts
All type definitions:
McpUiHostContext
, CSS variable keys, display modes
src/styles.ts
applyDocumentTheme
,
applyHostStyleVariables
,
applyHostFonts
src/react/useApp.tsx
useApp
hook for React apps
src/react/useHostStyles.ts
useHostStyles
,
useHostStyleVariables
,
useHostFonts
hooks

Advanced Examples

ExamplePattern Demonstrated
examples/shadertoy-server/
Streaming partial input + visibility-based pause/play (best practice for large inputs)
examples/wiki-explorer-server/
callServerTool
for interactive data fetching
examples/system-monitor-server/
Polling pattern with interval management
examples/video-resource-server/
Binary/blob resources
examples/sheet-music-server/
ontoolinput
- processing tool args before execution completes
examples/threejs-server/
ontoolinputpartial
- streaming/progressive rendering
examples/map-server/
updateModelContext
- keeping model informed of UI state
examples/transcript-server/
updateModelContext
+
sendMessage
- background context updates + user-initiated messages
examples/basic-host/
Reference host implementation using
AppBridge

Critical Implementation Notes

Adding Dependencies

Use

npm install
to add dependencies rather than manually writing version numbers:

npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod

This lets npm resolve the latest compatible versions. Never specify version numbers from memory.

TypeScript Server Execution

Use

tsx
as a devDependency for running TypeScript server files:

npm install -D tsx
"scripts": {
  "serve": "tsx server.ts"
}

Note: The SDK examples use

bun
but generated projects should use
tsx
for broader compatibility.

Handler Registration Order

Register ALL handlers BEFORE calling

app.connect()
:

const app = new App({ name: "My App", version: "1.0.0" });

// Register handlers first
app.ontoolinput = (params) => { /* handle input */ };
app.ontoolresult = (result) => { /* handle result */ };
app.onhostcontextchanged = (ctx) => { /* handle context */ };
app.onteardown = async () => { return {}; };

// Then connect
await app.connect();

Tool Visibility

Control who can access tools via

_meta.ui.visibility
:

// Default: visible to both model and app
_meta: { ui: { resourceUri, visibility: ["model", "app"] } }

// UI-only (hidden from model) - for refresh buttons, form submissions
_meta: { ui: { resourceUri, visibility: ["app"] } }

// Model-only (app cannot call)
_meta: { ui: { resourceUri, visibility: ["model"] } }

Host Styling Integration

Vanilla JS - Use helper functions:

import { applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";

app.onhostcontextchanged = (ctx) => {
  if (ctx.theme) applyDocumentTheme(ctx.theme);
  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
  if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
};

React - Use hooks:

import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";

const { app } = useApp({ appInfo, capabilities, onAppCreated });
useHostStyles(app); // Injects CSS variables to document, making var(--*) available

Using variables in CSS - After applying, use

var()
:

.container {
  background: var(--color-background-secondary);
  color: var(--color-text-primary);
  font-family: var(--font-sans);
  border-radius: var(--border-radius-md);
}
.code {
  font-family: var(--font-mono);
  font-size: var(--font-text-sm-size);
  line-height: var(--font-text-sm-line-height);
  color: var(--color-text-secondary);
}
.heading {
  font-size: var(--font-heading-lg-size);
  font-weight: var(--font-weight-semibold);
}

Key variable groups:

--color-background-*
,
--color-text-*
,
--color-border-*
,
--font-sans
,
--font-mono
,
--font-text-*-size
,
--font-heading-*-size
,
--border-radius-*
. See
src/spec.types.ts
for full list.

Safe Area Handling

Always respect

safeAreaInsets
:

app.onhostcontextchanged = (ctx) => {
  if (ctx.safeAreaInsets) {
    const { top, right, bottom, left } = ctx.safeAreaInsets;
    document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
  }
};

Streaming Partial Input

For large tool inputs, use

ontoolinputpartial
to show progress during LLM generation. The partial JSON is healed (always valid), enabling progressive UI updates.

Spec: ui/notifications/tool-input-partial

app.ontoolinputpartial = (params) => {
  const args = params.arguments; // Healed partial JSON - always valid, fields appear as generated
  // Use args directly for progressive rendering
};

app.ontoolinput = (params) => {
  // Final complete input - switch from preview to full render
};

Use cases:

PatternExample
Code previewShow streaming code in
<pre>
, render on complete (
examples/shadertoy-server/
)
Progressive formFill form fields as they stream in
Live chartAdd data points to chart as array grows
Partial renderRender incomplete structured data (tables, lists, trees)

Simple pattern (code preview):

app.ontoolinputpartial = (params) => {
  codePreview.textContent = params.arguments?.code ?? "";
  codePreview.style.display = "block";
  canvas.style.display = "none";
};
app.ontoolinput = (params) => {
  codePreview.style.display = "none";
  canvas.style.display = "block";
  render(params.arguments);
};

Visibility-Based Resource Management

Pause expensive operations (animations, WebGL, polling) when view scrolls out of viewport:

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      animation.play(); // or: startPolling(), shaderToy.play()
    } else {
      animation.pause(); // or: stopPolling(), shaderToy.pause()
    }
  });
});
observer.observe(document.querySelector(".main"));

Fullscreen Mode

Request fullscreen via

app.requestDisplayMode()
. Check availability in host context:

let currentMode: "inline" | "fullscreen" = "inline";

app.onhostcontextchanged = (ctx) => {
  // Check if fullscreen available
  if (ctx.availableDisplayModes?.includes("fullscreen")) {
    fullscreenBtn.style.display = "block";
  }
  // Track current mode
  if (ctx.displayMode) {
    currentMode = ctx.displayMode;
    container.classList.toggle("fullscreen", currentMode === "fullscreen");
  }
};

async function toggleFullscreen() {
  const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen";
  const result = await app.requestDisplayMode({ mode: newMode });
  currentMode = result.mode;
}

CSS pattern - Remove border radius in fullscreen:

.main { border-radius: var(--border-radius-lg); overflow: hidden; }
.main.fullscreen { border-radius: 0; }

See

examples/shadertoy-server/
for complete implementation.

Common Mistakes to Avoid

  1. Handlers after connect() - Register ALL handlers BEFORE calling
    app.connect()
  2. Missing single-file bundling - Must use
    vite-plugin-singlefile
  3. Forgetting resource registration - Both tool AND resource must be registered
  4. Missing resourceUri link - Tool must have
    _meta.ui.resourceUri
  5. Ignoring safe area insets - Always handle
    ctx.safeAreaInsets
  6. No text fallback - Always provide
    content
    array for non-UI hosts
  7. Hardcoded styles - Use host CSS variables for theme integration
  8. No streaming for large inputs - Use
    ontoolinputpartial
    to show progress during generation

Testing

Using basic-host

Test MCP Apps locally with the basic-host example:

# Terminal 1: Build and run your server
npm run build && npm run serve

# Terminal 2: Run basic-host (from cloned repo)
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# Open http://localhost:8080

Configure

SERVERS
with a JSON array of your server URLs (default:
http://localhost:3001/mcp
).

Debug with sendLog

Send debug logs to the host application (rather than just the iframe's dev console):

await app.sendLog({ level: "info", data: "Debug message" });
await app.sendLog({ level: "error", data: { error: err.message } });