Add UI to MCP Server
Enrich an existing MCP server's tools with interactive UIs using the MCP Apps SDK (@modelcontextprotocol/ext-apps).
How It Works
Existing tools get paired with HTML resources that render inline in the host's conversation. The tool continues to work for text-only clients — UI is an enhancement, not a replacement. Each tool that benefits from UI gets linked to a resource via _meta.ui.resourceUri, and the host renders that resource in a sandboxed iframe when the tool is called.
Getting Reference Code
Clone the SDK repository for working examples and API documentation:
bash
1git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
API Reference (Source Files)
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, getUiCapability, 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 |
Key Examples (Mixed Tool Patterns)
These examples demonstrate servers with both App-enhanced and plain tools — the exact pattern you're adding:
| Example | Pattern |
|---|
examples/map-server/ | show-map (App tool) + geocode (plain tool) |
examples/pdf-server/ | display_pdf (App tool) + list_pdfs (plain tool) + read_pdf_bytes (app-only tool) |
examples/system-monitor-server/ | get-system-info (App tool) + poll-system-stats (app-only polling tool) |
Framework Templates
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 |
Step 1: Analyze Existing Tools
Before writing any code, analyze the server's existing tools and determine which ones benefit from UI.
- Read the server source and list all registered tools
- For each tool, assess whether it would benefit from UI (returns data that could be visualized, involves user interaction, etc.) vs. is fine as text-only (simple lookups, utility functions)
- Identify tools that could become app-only helpers (data the UI needs to poll/fetch but the model doesn't need to call directly)
- Present the analysis to the user and confirm which tools to enhance
Decision Framework
| Tool output type | UI benefit | Example |
|---|
| Structured data / lists / tables | High — interactive table, search, filtering | List of items, search results |
| Metrics / numbers over time | High — charts, gauges, dashboards | System stats, analytics |
| Media / rich content | High — viewer, player, renderer | Maps, PDFs, images, video |
| Simple text / confirmations | Low — text is fine | "File created", "Setting updated" |
| Data for other tools | Consider app-only | Polling endpoints, chunk loaders |
Step 2: Add Dependencies
bash
1npm install @modelcontextprotocol/ext-apps
2npm install -D vite vite-plugin-singlefile
Plus framework-specific dependencies if needed (e.g., react, react-dom, @vitejs/plugin-react for React).
Use npm install to add dependencies rather than manually writing version numbers. This lets npm resolve the latest compatible versions. Never specify version numbers from memory.
Step 3: Set Up the Build Pipeline
Vite Configuration
Create vite.config.ts with vite-plugin-singlefile to bundle the UI into a single HTML file:
typescript
1import { defineConfig } from "vite";
2import { viteSingleFile } from "vite-plugin-singlefile";
3
4export default defineConfig({
5 plugins: [viteSingleFile()],
6 build: {
7 outDir: "dist",
8 rollupOptions: {
9 input: "mcp-app.html", // one per UI, or one shared entry
10 },
11 },
12});
HTML Entry Point
Create mcp-app.html (or one per distinct UI if tools need different views):
html
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>MCP App</title>
7 </head>
8 <body>
9 <div id="root"></div>
10 <script type="module" src="./src/mcp-app.ts"></script>
11 </body>
12</html>
Build Scripts
Add build scripts to package.json. The UI must be built before the server code bundles it:
json
1{
2 "scripts": {
3 "build:ui": "vite build",
4 "build:server": "tsc",
5 "build": "npm run build:ui && npm run build:server",
6 "serve": "tsx server.ts"
7 }
8}
Step 4: Convert Tools to App Tools
Transform plain MCP tools into App tools with UI.
Before (plain MCP tool):
typescript
1server.tool("my-tool", { param: z.string() }, async (args) => {
2 const data = await fetchData(args.param);
3 return { content: [{ type: "text", text: JSON.stringify(data) }] };
4});
After (App tool with UI):
typescript
1import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
2
3const resourceUri = "ui://my-tool/mcp-app.html";
4
5registerAppTool(server, "my-tool", {
6 description: "Shows data with an interactive UI",
7 inputSchema: { param: z.string() },
8 _meta: { ui: { resourceUri } },
9}, async (args) => {
10 const data = await fetchData(args.param);
11 return {
12 content: [{ type: "text", text: JSON.stringify(data) }], // text fallback for non-UI hosts
13 structuredContent: { data }, // structured data for the UI
14 };
15});
Key guidance:
- Always keep the
content array with a text fallback for text-only clients
- Add
structuredContent for data the UI needs to render
- Link the tool to its resource via
_meta.ui.resourceUri
- Leave tools that don't benefit from UI unchanged — they stay as plain tools
Step 5: Register Resources
Register the HTML resource so the host can fetch it:
typescript
1import fs from "node:fs/promises";
2import path from "node:path";
3
4const resourceUri = "ui://my-tool/mcp-app.html";
5
6registerAppResource(server, {
7 uri: resourceUri,
8 name: "My Tool UI",
9 mimeType: RESOURCE_MIME_TYPE,
10}, async () => {
11 const html = await fs.readFile(
12 path.resolve(import.meta.dirname, "dist", "mcp-app.html"),
13 "utf-8",
14 );
15 return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] };
16});
If multiple tools share the same UI, they can reference the same resourceUri and the same resource registration.
Step 6: Build the UI
Handler Registration
Register ALL handlers BEFORE calling app.connect():
typescript
1import { App, PostMessageTransport, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";
2
3const app = new App({ name: "My App", version: "1.0.0" });
4
5app.ontoolinput = (params) => {
6 // Render the UI using params.arguments and/or params.structuredContent
7};
8
9app.ontoolresult = (result) => {
10 // Update UI with final tool result
11};
12
13app.onhostcontextchanged = (ctx) => {
14 if (ctx.theme) applyDocumentTheme(ctx.theme);
15 if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
16 if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
17 if (ctx.safeAreaInsets) {
18 const { top, right, bottom, left } = ctx.safeAreaInsets;
19 document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
20 }
21};
22
23app.onteardown = async () => {
24 return {};
25};
26
27await app.connect(new PostMessageTransport());
Host Styling
Use host CSS variables for theme integration:
css
1.container {
2 background: var(--color-background-secondary);
3 color: var(--color-text-primary);
4 font-family: var(--font-sans);
5 border-radius: var(--border-radius-md);
6}
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 the full list.
For React apps, use the useApp and useHostStyles hooks instead — see basic-server-react/ for the pattern.
Optional Enhancements
App-Only Helper Tools
Tools the UI calls but the model doesn't need to invoke directly (polling, pagination, chunk loading):
typescript
1registerAppTool(server, "poll-data", {
2 description: "Polls latest data for the UI",
3 _meta: { ui: { resourceUri, visibility: ["app"] } },
4}, async () => {
5 const data = await getLatestData();
6 return { content: [{ type: "text", text: JSON.stringify(data) }] };
7});
The UI calls these via app.callServerTool("poll-data", {}).
CSP Configuration
If the UI needs to load external resources (fonts, APIs, CDNs), declare the domains:
typescript
1registerAppResource(server, {
2 uri: resourceUri,
3 name: "My Tool UI",
4 mimeType: RESOURCE_MIME_TYPE,
5 _meta: {
6 ui: {
7 connectDomains: ["api.example.com"], // fetch/XHR targets
8 resourceDomains: ["cdn.example.com"], // scripts, styles, images
9 frameDomains: ["embed.example.com"], // nested iframes
10 },
11 },
12}, async () => { /* ... */ });
Streaming Partial Input
For large tool inputs, show progress during LLM generation:
typescript
1app.ontoolinputpartial = (params) => {
2 const args = params.arguments; // Healed partial JSON - always valid
3 // Render preview with partial data
4};
5
6app.ontoolinput = (params) => {
7 // Final complete input - switch to full render
8};
Graceful Degradation with getUiCapability()
Conditionally register App tools only when the client supports UI, falling back to text-only tools:
typescript
1import { getUiCapability, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
2
3server.server.oninitialized = () => {
4 const clientCapabilities = server.server.getClientCapabilities();
5 const uiCap = getUiCapability(clientCapabilities);
6
7 if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) {
8 // Client supports UI — register App tool
9 registerAppTool(server, "my-tool", {
10 description: "Shows data with interactive UI",
11 _meta: { ui: { resourceUri } },
12 }, appToolHandler);
13 } else {
14 // Text-only client — register plain tool
15 server.tool("my-tool", "Shows data", { param: z.string() }, plainToolHandler);
16 }
17};
Fullscreen Mode
Allow the UI to expand to fullscreen:
typescript
1app.onhostcontextchanged = (ctx) => {
2 if (ctx.availableDisplayModes?.includes("fullscreen")) {
3 fullscreenBtn.style.display = "block";
4 }
5 if (ctx.displayMode) {
6 container.classList.toggle("fullscreen", ctx.displayMode === "fullscreen");
7 }
8};
9
10async function toggleFullscreen() {
11 const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen";
12 const result = await app.requestDisplayMode({ mode: newMode });
13 currentMode = result.mode;
14}
Common Mistakes to Avoid
- Forgetting text
content fallback — Always include content array with text for non-UI hosts
- Registering handlers after
connect() — Register ALL handlers BEFORE calling app.connect()
- Missing
vite-plugin-singlefile — Without it, assets won't load in the sandboxed iframe
- Forgetting resource registration — The tool references a
resourceUri that must have a matching resource
- Hardcoding styles — Use host CSS variables (
var(--color-*)) for theme integration
- Not handling safe area insets — Always apply
ctx.safeAreaInsets in onhostcontextchanged
Testing
Using basic-host
Test the enhanced server with the basic-host example:
bash
1# Terminal 1: Build and run your server
2npm run build && npm run serve
3
4# Terminal 2: Run basic-host (from cloned repo)
5cd /tmp/mcp-ext-apps/examples/basic-host
6npm install
7SERVERS='["http://localhost:3001/mcp"]' npm run start
8# Open http://localhost:8080
Configure SERVERS with a JSON array of your server URLs (default: http://localhost:3001/mcp).
Verify
- Plain tools still work and return text output
- App tools render their UI in the iframe
ontoolinput handler fires with tool arguments
ontoolresult handler fires with tool result
- Host styling (theme, fonts, colors) applies correctly