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:
- Tool - Called by the LLM/host, returns data
- Resource - Serves the bundled HTML UI that displays the data
- Link - The tool's references the resource
Host calls tool → Server returns result → Host renders resource UI → UI receives result
Quick Start Decision Tree
Framework Selection
| Framework | SDK Support | Best For |
|---|
| React | hook provided | Teams familiar with React |
| Vanilla JS | Manual lifecycle | Simple apps, no build complexity |
| Vue/Svelte/Preact/Solid | Manual lifecycle | Framework preference |
Note that if the user prefers to write MCP Servers in Golang or Rust you should ask the user if they have a specific framework in mind for that language and if not help them search for a suitable framework / library that supports MCP Apps.
Project Context
Adding to existing MCP server:
- Import , from SDK
- Add tool registration with
- 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
Getting Reference Code
Clone the SDK repository for working examples and API documentation:
bash
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}/
:
| Template | Key Files |
|---|
| , , |
| , (uses hook) |
| , |
| , |
| , |
| , |
Each template includes:
- Complete with and
- Client-side app with all lifecycle handlers
- with
- with all required dependencies
- excluding and
API Reference (Source Files)
Read JSDoc documentation directly from
:
| File | Contents |
|---|
| class, handlers (, , , ), lifecycle |
| , , tool visibility options |
| All type definitions: , CSS variable keys, display modes |
| , , |
| hook for React apps |
src/react/useHostStyles.ts
| , , hooks |
Advanced Examples
| Example | Pattern Demonstrated |
|---|
examples/shadertoy-server/
| Streaming partial input + visibility-based pause/play (best practice for large inputs) |
examples/wiki-explorer-server/
| for interactive data fetching |
examples/system-monitor-server/
| Polling pattern with interval management |
examples/video-resource-server/
| Binary/blob resources |
examples/sheet-music-server/
| - processing tool args before execution completes |
| - streaming/progressive rendering |
| - keeping model informed of UI state |
examples/transcript-server/
| + - background context updates + user-initiated messages |
| Reference host implementation using |
Critical Implementation Notes
Adding Dependencies
Use
to add dependencies rather than manually writing version numbers:
bash
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
as a devDependency for running TypeScript server files:
json
"scripts": {
"serve": "tsx server.ts"
}
Note: The SDK examples use
but generated projects should use
for broader compatibility.
Handler Registration Order
Register ALL handlers BEFORE calling
:
typescript
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
:
typescript
// 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:
typescript
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:
typescript
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
:
css
.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:
,
,
,
,
,
,
,
. See
for full list.
Safe Area Handling
typescript
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
to show progress during LLM generation. The partial JSON is healed (always valid), enabling progressive UI updates.
typescript
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:
| Pattern | Example |
|---|
| Code preview | Show streaming code in , render on complete (examples/shadertoy-server/
) |
| Progressive form | Fill form fields as they stream in |
| Live chart | Add data points to chart as array grows |
| Partial render | Render incomplete structured data (tables, lists, trees) |
Simple pattern (code preview):
typescript
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:
typescript
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
. Check availability in host context:
typescript
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:
css
.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
- Handlers after connect() - Register ALL handlers BEFORE calling
- Missing single-file bundling - Must use
- Forgetting resource registration - Both tool AND resource must be registered
- Missing resourceUri link - Tool must have
- Ignoring safe area insets - Always handle
- No text fallback - Always provide array for non-UI hosts
- Hardcoded styles - Use host CSS variables for theme integration
- No streaming for large inputs - Use to show progress during generation
Testing
Using basic-host
Test MCP Apps locally with the basic-host example:
bash
# 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
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):
typescript
await app.sendLog({ level: "info", data: "Debug message" });
await app.sendLog({ level: "error", data: { error: err.message } });