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.
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
_meta.ui.resourceUri references the resource
Host calls tool → Server returns result → Host renders resource UI → UI receives result
| Framework | SDK Support | Best For |
|---|
React | useApp hook provided
| Teams familiar with React |
Vanilla JS | Manual lifecycle | Simple apps, no build complexity |
Vue/Svelte/Preact/Solid | Manual lifecycle | Framework preference |
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
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
Learn and adapt from /tmp/mcp-ext-apps/examples/basic-server-{framework}/:
| Template | Key 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/
Read JSDoc documentation directly from /tmp/mcp-ext-apps/src/:
| File | Contents |
|---|
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
|
| Example | Pattern 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 |
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.
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.
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()
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"] } }
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.
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`
}
}
For large tool inputs, use ontoolinputpartial to show progress during LLM generation. The partial JSON is healed (always valid), enabling progressive UI updates.
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 <pre>, 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):
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)
}
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'))
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.
- Handlers after connect() - Register ALL handlers BEFORE calling
app.connect()
- Missing single-file bundling - Must use
vite-plugin-singlefile
- Forgetting resource registration - Both tool AND resource must be registered
- Missing resourceUri link - Tool must have
_meta.ui.resourceUri
- Ignoring safe area insets - Always handle
ctx.safeAreaInsets
- No text fallback - Always provide
content array for non-UI hosts
- Hardcoded styles - Use host CSS variables for theme integration
- No streaming for large inputs - Use
ontoolinputpartial to show progress during generation
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).
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 } })