MCP Apps
Build interactive UIs that run inside MCP-enabled hosts. An MCP App combines an MCP tool with an HTML resource.
Core Concept
Every MCP App requires two linked parts:
code
Host calls tool → Server returns result → Host renders resource UI → UI receives result
- •Tool - Called by LLM/host, returns data
- •Resource - Serves bundled HTML UI (via
ui://scheme) - •Link - Tool's
_meta.ui.resourceUrireferences the resource
Get Reference Code
Clone the SDK for working examples:
bash
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
Templates (/tmp/mcp-ext-apps/examples/)
| Template | Use For |
|---|---|
basic-server-react/ | React apps with useApp hook |
basic-server-vanillajs/ | Simple apps, no framework |
basic-server-vue/ | Vue apps |
basic-server-svelte/ | Svelte apps |
API Reference (/tmp/mcp-ext-apps/src/)
| File | Contents |
|---|---|
app.ts | App class, handlers, lifecycle |
server/index.ts | registerAppTool, registerAppResource |
react/useApp.tsx | React hook |
styles.ts | Host styling helpers |
Implementation Checklist
Server Side
- •
Install dependencies:
bashnpm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod npm install -D tsx vite vite-plugin-singlefile
- •
Register tool with UI metadata:
typescriptimport { registerAppTool, registerAppResource } from "@modelcontextprotocol/ext-apps/server"; registerAppTool(server, { name: "my_tool", description: "Tool description", inputSchema: zodToJsonSchema(MyInputSchema), _meta: { ui: { resourceUri: "ui://my-app/main", visibility: ["model", "app"] // or ["app"] for UI-only } }, handler: async (args) => ({ content: [{ type: "text", text: "Fallback for non-UI hosts" }], data: args // Passed to UI via ontoolresult }) }); registerAppResource(server, { uri: "ui://my-app/main", name: "My App UI", mimeType: "text/html", async handler() { return fs.readFileSync("./dist/mcp-app.html", "utf-8"); } });
Client Side (UI)
Register handlers BEFORE connect():
typescript
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "My App", version: "1.0.0" });
// Tool input (args before execution)
app.ontoolinput = (params) => {
console.log("Input:", params.arguments);
};
// Tool result (after execution)
app.ontoolresult = (result) => {
renderUI(result.data);
};
// Host context changes (theme, safe area)
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`;
}
};
// Cleanup
app.onteardown = async () => ({ state: {} });
await app.connect();
Build Config
Use vite-plugin-singlefile to bundle into one HTML file:
typescript
// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: "mcp-app.html"
}
}
});
Key Patterns
Host Styling
Use CSS variables from host context:
typescript
import { applyDocumentTheme, applyHostStyleVariables } from "@modelcontextprotocol/ext-apps";
app.onhostcontextchanged = (ctx) => {
if (ctx.theme) applyDocumentTheme(ctx.theme);
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
};
css
.container {
background: var(--color-background-secondary);
color: var(--color-text-primary);
font-family: var(--font-sans);
}
Streaming Partial Input
Show progress during LLM generation:
typescript
app.ontoolinputpartial = (params) => {
// Healed partial JSON - always valid
preview.textContent = JSON.stringify(params.arguments, null, 2);
};
app.ontoolinput = (params) => {
// Final complete input
render(params.arguments);
};
Call Server Tools from UI
typescript
const response = await app.callServerTool({
name: "fetch_details",
arguments: { id: "123" }
});
Update Model Context
Keep the model informed of UI state:
typescript
await app.updateModelContext({
content: [{ type: "text", text: "User selected option B" }]
});
Tool Visibility
typescript
// Both model and app can call (default) visibility: ["model", "app"] // UI-only (hidden from model) - for refresh buttons, form submissions visibility: ["app"] // Model-only visibility: ["model"]
Testing
Use the basic-host example:
bash
# Terminal 1: Your server npm run build && npm run serve # Terminal 2: Test host cd /tmp/mcp-ext-apps/examples/basic-host npm install SERVERS='["http://localhost:3001/mcp"]' npm run start # Open http://localhost:8080
Common Mistakes
- •Handlers after connect() - Register ALL handlers BEFORE
app.connect() - •Missing vite-plugin-singlefile - Required for bundling
- •No resourceUri link - Tool must have
_meta.ui.resourceUri - •Ignoring safe area insets - Always handle
ctx.safeAreaInsets - •Hardcoded styles - Use host CSS variables