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:
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.resourceUri references the resource
Get Reference Code
Clone the SDK for working examples:
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:
``bash npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk zod npm install -D tsx vite vite-plugin-singlefile ``
- Register tool with UI metadata:
```typescript import { registerAppTool, registerAppResource } from "@modelcontextprotocol/ext-apps/server";
registerAppTool(server, { name: "mytool", 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():
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:
// 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:
import { applyDocumentTheme, applyHostStyleVariables } from "@modelcontextprotocol/ext-apps";
app.onhostcontextchanged = (ctx) => {
if (ctx.theme) applyDocumentTheme(ctx.theme);
if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
};
.container {
background: var(--color-background-secondary);
color: var(--color-text-primary);
font-family: var(--font-sans);
}
Streaming Partial Input
Show progress during LLM generation:
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
const response = await app.callServerTool({
name: "fetch_details",
arguments: { id: "123" }
});
Update Model Context
Keep the model informed of UI state:
await app.updateModelContext({
content: [{ type: "text", text: "User selected option B" }]
});
Tool Visibility
// 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:
# 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