Skip to content

Commit 277b511

Browse files
authored
Merge pull request #157 from QuantGeekDev/feat/mcp-apps
feat: add MCP Apps support (interactive UI from tools)
2 parents ef90b23 + 4eea48a commit 277b511

14 files changed

Lines changed: 1815 additions & 2 deletions

File tree

docs/apps.md

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
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

Comments
 (0)