|
| 1 | +# MCP Apps |
| 2 | + |
| 3 | +MCP Apps let your tools deliver interactive HTML UIs -- dashboards, forms, charts, visualizations -- that render inline in Claude, ChatGPT, VS Code, and other MCP hosts. Instead of returning only text, your tool can return a rich interactive experience. |
| 4 | + |
| 5 | +## How It Works |
| 6 | + |
| 7 | +MCP Apps is built on the [SEP-1865 specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx), the first official MCP extension. The mechanism is: |
| 8 | + |
| 9 | +1. Your server registers a `ui://` resource containing HTML |
| 10 | +2. Your tool definition includes `_meta.ui.resourceUri` pointing to that resource |
| 11 | +3. The host fetches the HTML and renders it in a sandboxed iframe |
| 12 | +4. The iframe communicates with the host via JSON-RPC over `postMessage` |
| 13 | + |
| 14 | +**Graceful degradation**: Hosts that don't support MCP Apps see normal text tool results. Your `execute()` return value is always the text fallback. |
| 15 | + |
| 16 | +## Two Modes |
| 17 | + |
| 18 | +mcp-framework provides two ways to add MCP Apps: |
| 19 | + |
| 20 | +### Mode A: Standalone MCPApp |
| 21 | + |
| 22 | +For apps with multiple tools or complex UI, create an `MCPApp` subclass in `src/apps/`. The framework auto-discovers it just like tools, resources, and prompts. |
| 23 | + |
| 24 | +```typescript |
| 25 | +import { MCPApp } from "mcp-framework"; |
| 26 | +import { z } from "zod"; |
| 27 | +import { readFileSync } from "fs"; |
| 28 | +import { join, dirname } from "path"; |
| 29 | +import { fileURLToPath } from "url"; |
| 30 | + |
| 31 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 32 | + |
| 33 | +class DashboardApp extends MCPApp { |
| 34 | + name = "dashboard"; |
| 35 | + |
| 36 | + ui = { |
| 37 | + resourceUri: "ui://dashboard/view", |
| 38 | + resourceName: "Analytics Dashboard", |
| 39 | + resourceDescription: "Interactive analytics with charts and filters", |
| 40 | + csp: { |
| 41 | + connectDomains: ["https://api.analytics.com"], |
| 42 | + resourceDomains: ["https://cdn.jsdelivr.net"], |
| 43 | + }, |
| 44 | + prefersBorder: true, |
| 45 | + }; |
| 46 | + |
| 47 | + getContent() { |
| 48 | + return readFileSync( |
| 49 | + join(__dirname, "../../app-views/dashboard/index.html"), |
| 50 | + "utf-8" |
| 51 | + ); |
| 52 | + } |
| 53 | + |
| 54 | + tools = [ |
| 55 | + { |
| 56 | + name: "show_dashboard", |
| 57 | + description: "Display the analytics dashboard", |
| 58 | + schema: z.object({ |
| 59 | + timeRange: z.string().describe("Time range (e.g., '7d', '30d')"), |
| 60 | + metrics: z.array(z.string()).optional().describe("Metrics to display"), |
| 61 | + }), |
| 62 | + execute: async (input: { timeRange: string; metrics?: string[] }) => { |
| 63 | + const data = await fetchAnalytics(input.timeRange, input.metrics); |
| 64 | + return { data, summary: `Analytics for ${input.timeRange}` }; |
| 65 | + }, |
| 66 | + }, |
| 67 | + { |
| 68 | + // App-only tool: the UI can call this, but the LLM can't see it |
| 69 | + name: "refresh_data", |
| 70 | + description: "Refresh a specific metric", |
| 71 | + visibility: ["app"] as const, |
| 72 | + schema: z.object({ |
| 73 | + metric: z.string().describe("Metric to refresh"), |
| 74 | + }), |
| 75 | + execute: async (input: { metric: string }) => { |
| 76 | + return await fetchMetric(input.metric); |
| 77 | + }, |
| 78 | + }, |
| 79 | + ]; |
| 80 | +} |
| 81 | + |
| 82 | +export default DashboardApp; |
| 83 | +``` |
| 84 | + |
| 85 | +### Mode B: Tool-Attached App |
| 86 | + |
| 87 | +For simpler cases, add a UI to an existing tool by declaring an `app` property: |
| 88 | + |
| 89 | +```typescript |
| 90 | +import { MCPTool, MCPInput } from "mcp-framework"; |
| 91 | +import { z } from "zod"; |
| 92 | +import { readFileSync } from "fs"; |
| 93 | + |
| 94 | +const schema = z.object({ |
| 95 | + location: z.string().describe("City name"), |
| 96 | +}); |
| 97 | + |
| 98 | +class WeatherTool extends MCPTool { |
| 99 | + name = "get_weather"; |
| 100 | + description = "Get weather with interactive visualization"; |
| 101 | + schema = schema; |
| 102 | + |
| 103 | + app = { |
| 104 | + resourceUri: "ui://weather/view", |
| 105 | + resourceName: "Weather View", |
| 106 | + content: () => readFileSync("./app-views/weather/index.html", "utf-8"), |
| 107 | + csp: { connectDomains: ["https://api.openweathermap.org"] }, |
| 108 | + }; |
| 109 | + |
| 110 | + async execute(input: MCPInput<this>) { |
| 111 | + const weather = await fetchWeather(input.location); |
| 112 | + return weather; // Text fallback for non-UI hosts |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +export default WeatherTool; |
| 117 | +``` |
| 118 | + |
| 119 | +## Scaffolding |
| 120 | + |
| 121 | +Generate an app with the CLI: |
| 122 | + |
| 123 | +```bash |
| 124 | +mcp add app my-dashboard |
| 125 | +``` |
| 126 | + |
| 127 | +This creates: |
| 128 | +- `src/apps/MyDashboardApp.ts` -- the MCPApp subclass |
| 129 | +- `src/app-views/my-dashboard/index.html` -- the HTML view template |
| 130 | + |
| 131 | +## Project Structure |
| 132 | + |
| 133 | +``` |
| 134 | +my-mcp-server/ |
| 135 | +├── src/ |
| 136 | +│ ├── tools/ # Regular tools (auto-discovered) |
| 137 | +│ ├── apps/ # MCPApp subclasses (auto-discovered) |
| 138 | +│ ├── app-views/ # HTML templates for apps |
| 139 | +│ │ └── my-dashboard/ |
| 140 | +│ │ └── index.html |
| 141 | +│ ├── resources/ |
| 142 | +│ ├── prompts/ |
| 143 | +│ └── index.ts |
| 144 | +``` |
| 145 | + |
| 146 | +## Writing the HTML View |
| 147 | + |
| 148 | +Your app's HTML runs inside a sandboxed iframe. It communicates with the host via JSON-RPC over `postMessage`. Here's a minimal template: |
| 149 | + |
| 150 | +```html |
| 151 | +<!DOCTYPE html> |
| 152 | +<html lang="en"> |
| 153 | +<head> |
| 154 | + <meta charset="utf-8" /> |
| 155 | + <style> |
| 156 | + :root { |
| 157 | + --color-background-primary: light-dark(#ffffff, #1a1a1a); |
| 158 | + --color-text-primary: light-dark(#1a1a1a, #fafafa); |
| 159 | + --font-sans: system-ui, sans-serif; |
| 160 | + } |
| 161 | + body { |
| 162 | + margin: 0; padding: 16px; |
| 163 | + background: var(--color-background-primary); |
| 164 | + color: var(--color-text-primary); |
| 165 | + font-family: var(--font-sans); |
| 166 | + } |
| 167 | + </style> |
| 168 | +</head> |
| 169 | +<body> |
| 170 | + <div id="app">Loading...</div> |
| 171 | + <script type="module"> |
| 172 | + // Helper: send JSON-RPC request to host |
| 173 | + let nextId = 1; |
| 174 | + function sendRequest(method, params) { |
| 175 | + const id = nextId++; |
| 176 | + return new Promise((resolve, reject) => { |
| 177 | + function listener(event) { |
| 178 | + if (event.data?.id === id) { |
| 179 | + window.removeEventListener("message", listener); |
| 180 | + event.data?.result ? resolve(event.data.result) : reject(event.data?.error); |
| 181 | + } |
| 182 | + } |
| 183 | + window.addEventListener("message", listener); |
| 184 | + window.parent.postMessage({ jsonrpc: "2.0", id, method, params }, "*"); |
| 185 | + }); |
| 186 | + } |
| 187 | +
|
| 188 | + // Helper: listen for host notifications |
| 189 | + function onNotification(method, handler) { |
| 190 | + window.addEventListener("message", (event) => { |
| 191 | + if (event.data?.method === method) handler(event.data.params); |
| 192 | + }); |
| 193 | + } |
| 194 | +
|
| 195 | + // 1. Initialize — handshake with host |
| 196 | + const init = await sendRequest("initialize", { |
| 197 | + capabilities: {}, |
| 198 | + clientInfo: { name: "my-app", version: "1.0.0" }, |
| 199 | + protocolVersion: "2026-01-26", |
| 200 | + }); |
| 201 | +
|
| 202 | + // 2. Apply host theme (optional but recommended) |
| 203 | + const vars = init.hostContext?.styles?.variables; |
| 204 | + if (vars) { |
| 205 | + for (const [key, value] of Object.entries(vars)) { |
| 206 | + if (value) document.documentElement.style.setProperty(key, value); |
| 207 | + } |
| 208 | + } |
| 209 | +
|
| 210 | + // 3. Handle tool input (arguments passed to the tool) |
| 211 | + onNotification("ui/notifications/tool-input", (params) => { |
| 212 | + document.getElementById("app").innerHTML = |
| 213 | + "<pre>" + JSON.stringify(params.arguments, null, 2) + "</pre>"; |
| 214 | + }); |
| 215 | +
|
| 216 | + // 4. Handle tool result (output from execute()) |
| 217 | + onNotification("ui/notifications/tool-result", (params) => { |
| 218 | + const text = params.content?.[0]?.text ?? JSON.stringify(params); |
| 219 | + document.getElementById("app").innerHTML = "<pre>" + text + "</pre>"; |
| 220 | + }); |
| 221 | +
|
| 222 | + // 5. Signal ready |
| 223 | + window.parent.postMessage({ |
| 224 | + jsonrpc: "2.0", |
| 225 | + method: "notifications/initialized", |
| 226 | + params: {} |
| 227 | + }, "*"); |
| 228 | + </script> |
| 229 | +</body> |
| 230 | +</html> |
| 231 | +``` |
| 232 | +
|
| 233 | +### Using the Official SDK |
| 234 | +
|
| 235 | +For more complex apps, use `@modelcontextprotocol/ext-apps`: |
| 236 | +
|
| 237 | +```bash |
| 238 | +npm install @modelcontextprotocol/ext-apps |
| 239 | +``` |
| 240 | +
|
| 241 | +```typescript |
| 242 | +import { App } from "@modelcontextprotocol/ext-apps"; |
| 243 | +
|
| 244 | +const app = new App({ name: "my-app", version: "1.0.0" }); |
| 245 | +
|
| 246 | +app.ontoolinput = (params) => { |
| 247 | + // Render tool arguments |
| 248 | +}; |
| 249 | +
|
| 250 | +app.ontoolresult = (params) => { |
| 251 | + // Render results |
| 252 | +}; |
| 253 | +
|
| 254 | +await app.connect(); |
| 255 | +``` |
| 256 | +
|
| 257 | +React hooks are available via `@modelcontextprotocol/ext-apps/react`. |
| 258 | +
|
| 259 | +## UI Configuration |
| 260 | +
|
| 261 | +### Content Security Policy (CSP) |
| 262 | +
|
| 263 | +By default, the iframe has no network access. Declare allowed domains: |
| 264 | +
|
| 265 | +```typescript |
| 266 | +ui = { |
| 267 | + resourceUri: "ui://my-app/view", |
| 268 | + resourceName: "My App", |
| 269 | + csp: { |
| 270 | + connectDomains: ["https://api.example.com"], // fetch/XHR/WebSocket |
| 271 | + resourceDomains: ["https://cdn.example.com"], // scripts, images, fonts |
| 272 | + frameDomains: ["https://youtube.com"], // nested iframes |
| 273 | + }, |
| 274 | +}; |
| 275 | +``` |
| 276 | +
|
| 277 | +### Permissions |
| 278 | +
|
| 279 | +Request browser capabilities: |
| 280 | +
|
| 281 | +```typescript |
| 282 | +ui = { |
| 283 | + // ... |
| 284 | + permissions: { |
| 285 | + camera: {}, |
| 286 | + microphone: {}, |
| 287 | + geolocation: {}, |
| 288 | + clipboardWrite: {}, |
| 289 | + }, |
| 290 | +}; |
| 291 | +``` |
| 292 | +
|
| 293 | +Permissions are not guaranteed -- always use feature detection in your HTML. |
| 294 | +
|
| 295 | +### Tool Visibility |
| 296 | +
|
| 297 | +Control who can call each tool: |
| 298 | +
|
| 299 | +```typescript |
| 300 | +tools = [ |
| 301 | + { |
| 302 | + name: "show_ui", |
| 303 | + visibility: ["model", "app"], // Default: LLM and UI can both call |
| 304 | + // ... |
| 305 | + }, |
| 306 | + { |
| 307 | + name: "refresh_data", |
| 308 | + visibility: ["app"], // Only the UI can call this (hidden from LLM) |
| 309 | + // ... |
| 310 | + }, |
| 311 | +]; |
| 312 | +``` |
| 313 | +
|
| 314 | +## Dev Mode |
| 315 | +
|
| 316 | +In development, app HTML is re-read from disk on every `resources/read` request so you can iterate without restarting the server: |
| 317 | +
|
| 318 | +```typescript |
| 319 | +const server = new MCPServer({ |
| 320 | + devMode: true, // Or set MCP_DEV_MODE=1 env var |
| 321 | +}); |
| 322 | +``` |
| 323 | +
|
| 324 | +In production (default), HTML is cached at startup for performance. |
| 325 | +
|
| 326 | +## Bundling |
| 327 | +
|
| 328 | +The host expects a single HTML file with all JS/CSS inlined. For complex apps, use [Vite](https://vite.dev/) with `vite-plugin-singlefile`: |
| 329 | +
|
| 330 | +```bash |
| 331 | +npm install -D vite vite-plugin-singlefile |
| 332 | +``` |
| 333 | +
|
| 334 | +```typescript |
| 335 | +// vite.config.ts |
| 336 | +import { defineConfig } from "vite"; |
| 337 | +import { viteSingleFile } from "vite-plugin-singlefile"; |
| 338 | +
|
| 339 | +export default defineConfig({ |
| 340 | + plugins: [viteSingleFile()], |
| 341 | + build: { |
| 342 | + outDir: "src/app-views/my-app", |
| 343 | + rollupOptions: { input: "src/app-views/my-app/src/index.html" }, |
| 344 | + }, |
| 345 | +}); |
| 346 | +``` |
| 347 | +
|
| 348 | +For simple apps, you can skip bundling entirely and write inline HTML. |
| 349 | +
|
| 350 | +## Client Support |
| 351 | +
|
| 352 | +MCP Apps is supported by: |
| 353 | +- **Claude** (web and desktop) |
| 354 | +- **ChatGPT** |
| 355 | +- **VS Code** (GitHub Copilot) |
| 356 | +- **Goose** |
| 357 | +- **Postman** |
| 358 | +
|
| 359 | +Hosts that don't support MCP Apps see normal text tool results -- your server works everywhere. |
| 360 | +
|
| 361 | +## Use Cases |
| 362 | +
|
| 363 | +- **Data dashboards** -- interactive charts with drill-down, filtering, and real-time updates |
| 364 | +- **Configuration wizards** -- multi-step forms with validation |
| 365 | +- **Code diff viewers** -- syntax-highlighted diffs with inline comments |
| 366 | +- **Map/location pickers** -- interactive maps for coordinate selection |
| 367 | +- **Document reviewers** -- annotatable document views |
| 368 | +- **Database explorers** -- sortable, filterable query result tables |
0 commit comments