add(plugin): mcp-server-dev — skills for building MCP servers

This commit is contained in:
Tobin South
2026-03-20 10:43:05 -07:00
parent d687c591f4
commit e8abd6b19b
20 changed files with 2901 additions and 0 deletions

View File

@@ -0,0 +1,352 @@
---
name: build-mcp-app
description: This skill should be used when the user wants to build an "MCP app", add "interactive UI" or "widgets" to an MCP server, "render components in chat", build "MCP UI resources", make a tool that shows a "form", "picker", "dashboard" or "confirmation dialog" inline in the conversation, or mentions "apps SDK" in the context of MCP. Use AFTER the build-mcp-server skill has settled the deployment model, or when the user already knows they want UI widgets.
version: 0.1.0
---
# Build an MCP App (Interactive UI Widgets)
An MCP app is a standard MCP server that **also serves UI resources** — interactive components rendered inline in the chat surface. Build once, runs in Claude *and* ChatGPT and any other host that implements the apps surface.
The UI layer is **additive**. Under the hood it's still tools, resources, and the same wire protocol. If you haven't built a plain MCP server before, the `build-mcp-server` skill covers the base layer. This skill adds widgets on top.
---
## When a widget beats plain text
Don't add UI for its own sake — most tools are fine returning text or JSON. Add a widget when one of these is true:
| Signal | Widget type |
|---|---|
| Tool needs structured input Claude can't reliably infer | Form |
| User must pick from a list Claude can't rank (files, contacts, records) | Picker / table |
| Destructive or billable action needs explicit confirmation | Confirm dialog |
| Output is spatial or visual (charts, maps, diffs, previews) | Display widget |
| Long-running job the user wants to watch | Progress / live status |
If none apply, skip the widget. Text is faster to build and faster for the user.
---
## Widgets vs Elicitation — route correctly
Before building a widget, check if **elicitation** covers it. Elicitation is spec-native, zero UI code, works in any compliant host.
| Need | Elicitation | Widget |
|---|---|---|
| Confirm yes/no | ✅ | overkill |
| Pick from short enum | ✅ | overkill |
| Fill a flat form (name, email, date) | ✅ | overkill |
| Pick from a large/searchable list | ❌ (no scroll/search) | ✅ |
| Visual preview before choosing | ❌ | ✅ |
| Chart / map / diff view | ❌ | ✅ |
| Live-updating progress | ❌ | ✅ |
If elicitation covers it, use it. See `../build-mcp-server/references/elicitation.md`.
---
## Architecture: two deployment shapes
### Remote MCP app (most common)
Hosted streamable-HTTP server. Widget templates are served as **resources**; tool results reference them. The host fetches the resource, renders it in an iframe sandbox, and brokers messages between the widget and Claude.
```
┌──────────┐ tools/call ┌────────────┐
│ Claude │─────────────> │ MCP server │
│ host │<── result ────│ (remote) │
│ │ + widget ref │ │
│ │ │ │
│ │ resources/read│ │
│ │─────────────> │ widget │
│ ┌──────┐ │<── template ──│ HTML/JS │
│ │iframe│ │ └────────────┘
│ │widget│ │
│ └──────┘ │
└──────────┘
```
### MCPB-packaged MCP app (local + UI)
Same widget mechanism, but the server runs locally inside an MCPB bundle. Use this when the widget needs to drive a **local** application — e.g., a file picker that browses the actual local disk, a dialog that controls a desktop app.
For MCPB packaging mechanics, defer to the **`build-mcpb`** skill. Everything below applies to both shapes.
---
## How widgets attach to tools
A widget-enabled tool has **two separate registrations**:
1. **The tool** declares a UI resource via `_meta.ui.resourceUri`. Its handler returns plain text/JSON — NOT the HTML.
2. **The resource** is registered separately and serves the HTML.
When Claude calls the tool, the host sees `_meta.ui.resourceUri`, fetches that resource, renders it in an iframe, and pipes the tool's return value into the iframe via the `ontoolresult` event.
```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
const server = new McpServer({ name: "contacts", version: "1.0.0" });
// 1. The tool — returns DATA, declares which UI to show
registerAppTool(server, "pick_contact", {
description: "Open an interactive contact picker",
inputSchema: { filter: z.string().optional() },
_meta: { ui: { resourceUri: "ui://widgets/contact-picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter);
// Plain JSON — the widget receives this via ontoolresult
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
// 2. The resource — serves the HTML
registerAppResource(
server,
"Contact Picker",
"ui://widgets/contact-picker.html",
{},
async () => ({
contents: [{
uri: "ui://widgets/contact-picker.html",
mimeType: RESOURCE_MIME_TYPE,
text: pickerHtml, // your HTML string
}],
}),
);
```
The URI scheme `ui://` is convention. The mime type MUST be `RESOURCE_MIME_TYPE` (`"text/html;profile=mcp-app"`) — this is how the host knows to render it as an interactive iframe, not just display the source.
---
## Widget runtime — the `App` class
Inside the iframe, your script talks to the host via the `App` class from `@modelcontextprotocol/ext-apps`. This is a **persistent bidirectional connection** — the widget stays alive as long as the conversation is active, receiving new tool results and sending user actions.
```html
<script type="module">
/* ext-apps bundle inlined at build time → globalThis.ExtApps */
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
// Set handlers BEFORE connecting
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
render(contacts);
};
await app.connect();
// Later, when the user clicks something:
function onPick(contact) {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${contact.id}` }],
});
}
</script>
```
The `/*__EXT_APPS_BUNDLE__*/` placeholder gets replaced by the server at startup with the contents of `@modelcontextprotocol/ext-apps/app-with-deps` — see `references/iframe-sandbox.md` for why this is necessary and the rewrite snippet. **Do not** `import { App } from "https://esm.sh/..."`; the iframe's CSP blocks the transitive dependency fetches and the widget renders blank.
| Method | Direction | Use for |
|---|---|---|
| `app.ontoolresult = fn` | Host → widget | Receive the tool's return value |
| `app.ontoolinput = fn` | Host → widget | Receive the tool's input args (what Claude passed) |
| `app.sendMessage({...})` | Widget → host | Inject a message into the conversation |
| `app.updateModelContext({...})` | Widget → host | Update context silently (no visible message) |
| `app.callServerTool({name, arguments})` | Widget → server | Call another tool on your server |
| `app.openLink({url})` | Widget → host | Open a URL in a new tab (sandbox blocks `window.open`) |
| `app.getHostContext()` / `app.onhostcontextchanged` | Host → widget | Theme (`light`/`dark`), locale, etc. |
`sendMessage` is the typical "user picked something, tell Claude" path. `updateModelContext` is for state that Claude should know about but shouldn't clutter the chat. `openLink` is **required** for any outbound navigation — `window.open` and `<a target="_blank">` are blocked by the sandbox attribute.
**What widgets cannot do:**
- Access the host page's DOM, cookies, or storage
- Make network calls to arbitrary origins (CSP-restricted — route through `callServerTool`)
- Open popups or navigate directly — use `app.openLink({url})`
- Load remote images reliably — inline as `data:` URLs server-side
Keep widgets **small and single-purpose**. A picker picks. A chart displays. Don't build a whole sub-app inside the iframe — split it into multiple tools with focused widgets.
---
## Scaffold: minimal picker widget
**Install:**
```bash
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod express
```
**Server (`src/server.ts`):**
```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
from "@modelcontextprotocol/ext-apps/server";
import express from "express";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { z } from "zod";
const require = createRequire(import.meta.url);
const server = new McpServer({ name: "contact-picker", version: "1.0.0" });
// Inline the ext-apps browser bundle into the widget HTML.
// The iframe CSP blocks CDN script fetches — bundling is mandatory.
const bundle = readFileSync(
require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"), "utf8",
).replace(/export\{([^}]+)\};?\s*$/, (_, body) =>
"globalThis.ExtApps={" +
body.split(",").map((p) => {
const [local, exported] = p.split(" as ").map((s) => s.trim());
return `${exported ?? local}:${local}`;
}).join(",") + "};",
);
const pickerHtml = readFileSync("./widgets/picker.html", "utf8")
.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);
registerAppTool(server, "pick_contact", {
description: "Open an interactive contact picker. User selects one contact.",
inputSchema: { filter: z.string().optional().describe("Name/email prefix filter") },
_meta: { ui: { resourceUri: "ui://widgets/picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter ?? "");
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
registerAppResource(server, "Contact Picker", "ui://widgets/picker.html", {},
async () => ({
contents: [{ uri: "ui://widgets/picker.html", mimeType: RESOURCE_MIME_TYPE, text: pickerHtml }],
}),
);
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(process.env.PORT ?? 3000);
```
For local-only widget apps (driving a desktop app, reading local files), swap the transport to `StdioServerTransport` and package via the `build-mcpb` skill.
**Widget (`widgets/picker.html`):**
```html
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 0; }
ul { list-style: none; padding: 0; margin: 0; max-height: 300px; overflow-y: auto; }
li { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid #eee; }
li:hover { background: #f5f5f5; }
.sub { color: #666; font-size: 12px; }
</style>
<ul id="list"></ul>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
const ul = document.getElementById("list");
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
ul.innerHTML = "";
for (const c of contacts) {
const li = document.createElement("li");
li.innerHTML = `<div>${c.name}</div><div class="sub">${c.email}</div>`;
li.addEventListener("click", () => {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${c.id} (${c.name})` }],
});
});
ul.append(li);
}
};
await app.connect();
})();
</script>
```
See `references/widget-templates.md` for more widget shapes.
---
## Design notes that save you a rewrite
**One widget per tool.** Resist the urge to build one mega-widget that does everything. One tool → one focused widget → one clear result shape. Claude reasons about these far better.
**Tool description must mention the widget.** Claude only sees the tool description when deciding what to call. "Opens an interactive picker" in the description is what makes Claude reach for it instead of guessing an ID.
**Widgets are optional at runtime.** Hosts that don't support the apps surface simply ignore `_meta.ui` and render the tool's text content normally. Since your tool handler already returns meaningful text/JSON (the widget's data), degradation is automatic — Claude sees the data directly instead of via the widget.
**Don't block on widget results for read-only tools.** A widget that just *displays* data (chart, preview) shouldn't require a user action to complete. Return the display widget *and* a text summary in the same result so Claude can continue reasoning without waiting.
**Layout-fork by item count, not by tool count.** If one use case is "show one result in detail" and another is "show many results side-by-side", don't make two tools — make one tool that accepts `items[]`, and let the widget pick a layout: `items.length === 1` → detail view, `> 1` → carousel. Keeps the server schema simple and lets Claude decide count naturally.
**Put Claude's reasoning in the payload.** A short `note` field on each item (why Claude picked it) rendered as a callout on the card gives users the reasoning inline with the choice. Mention this field in the tool description so Claude populates it.
**Normalize image shapes server-side.** If your data source returns images with wildly varying aspect ratios, rewrite to a predictable variant (e.g. square-bounded) *before* fetching for the data-URL inline. Then give the widget's image container a fixed `aspect-ratio` + `object-fit: contain` so everything sits centered.
**Follow host theme.** `app.getHostContext()?.theme` (after `connect()`) plus `app.onhostcontextchanged` for live updates. Toggle a `.dark` class on `<html>`, keep colors in CSS custom props with a `:root.dark {}` override block, set `color-scheme`. Disable `mix-blend-mode: multiply` in dark — it makes images vanish.
---
## Testing
**Claude Desktop** — current builds still require the `command`/`args` config shape (no native `"type": "http"`). Wrap with `mcp-remote` and force `http-only` transport so the SSE probe doesn't swallow widget-capability negotiation:
```json
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/mcp",
"--allow-http", "--transport", "http-only"]
}
}
}
```
Desktop caches UI resources aggressively. After editing widget HTML, **fully quit** (⌘Q / Alt+F4, not window-close) and relaunch to force a cold resource re-fetch.
**Headless JSON-RPC loop** — fast iteration without clicking through Desktop:
```bash
# test.jsonl — one JSON-RPC message per line
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"your_tool","arguments":{...}}}
(cat test.jsonl; sleep 10) | npx mcp-remote http://localhost:3000/mcp --allow-http
```
The `sleep` keeps stdin open long enough to collect all responses. Parse the jsonl output with `jq` or a Python one-liner.
**Host fallback** — use a host without the apps surface (or MCP Inspector) and confirm the tool's text content degrades gracefully.
**CSP debugging** — open the iframe's own devtools console. CSP violations are the #1 reason widgets silently fail (blank rectangle, no error in the main console). See `references/iframe-sandbox.md`.
---
## Reference files
- `references/iframe-sandbox.md` — CSP/sandbox constraints, the bundle-inlining pattern, image handling
- `references/widget-templates.md` — reusable HTML scaffolds for picker / confirm / progress / display
- `references/apps-sdk-messages.md` — the `App` class API: widget ↔ host ↔ server messaging

View File

@@ -0,0 +1,160 @@
# ext-apps messaging — widget ↔ host ↔ server
The `@modelcontextprotocol/ext-apps` package provides the `App` class (browser side) and `registerAppTool`/`registerAppResource` helpers (server side). Messaging is bidirectional and persistent.
---
## Widget → Host
### `app.sendMessage({ role, content })`
Inject a visible message into the conversation. This is how user actions become conversation turns.
```js
app.sendMessage({
role: "user",
content: [{ type: "text", text: "User selected order #1234" }],
});
```
The message appears in chat and Claude responds to it. Use `role: "user"` — the widget speaks on the user's behalf.
### `app.updateModelContext({ content })`
Update Claude's context **silently** — no visible message. Use for state that informs but doesn't warrant a chat bubble.
```js
app.updateModelContext({
content: [{ type: "text", text: "Currently viewing: orders from last 30 days" }],
});
```
### `app.callServerTool({ name, arguments })`
Call a tool on your MCP server directly, bypassing Claude. Returns the tool result.
```js
const result = await app.callServerTool({
name: "fetch_order_details",
arguments: { orderId: "1234" },
});
```
Use for data fetches that don't need Claude's reasoning — pagination, detail lookups, refreshes.
### `app.openLink({ url })`
Open a URL in a new browser tab, host-mediated. **Required** for any outbound navigation — the iframe sandbox blocks `window.open()` and `<a target="_blank">`.
```js
await app.openLink({ url: "https://example.com/cart" });
```
For anchors in rendered HTML, intercept the click:
```js
card.querySelector("a").addEventListener("click", (e) => {
e.preventDefault();
app.openLink({ url: e.currentTarget.href });
});
```
### `app.downloadFile({ name, mimeType, content })`
Host-mediated download (sandbox blocks direct `<a download>`). `content` is a base64 string.
---
## Host → Widget
### `app.ontoolresult = ({ content }) => {...}`
Fires when the tool handler's return value is piped to the widget. This is the primary data-in path.
```js
app.ontoolresult = ({ content }) => {
const data = JSON.parse(content[0].text);
renderUI(data);
};
```
**Set this BEFORE `await app.connect()`** — the result may arrive immediately after connection.
### `app.ontoolinput = ({ arguments }) => {...}`
Fires with the arguments Claude passed to the tool. Useful if the widget needs to know what was asked for (e.g., highlight the search term).
### `app.getHostContext()` / `app.onhostcontextchanged = (ctx) => {...}`
Read and subscribe to host context — `theme` (`"light"` / `"dark"`), locale, etc. Call `getHostContext()` **after** `connect()`. Subscribe for live updates (user toggles dark mode mid-conversation).
```js
const applyTheme = (t) =>
document.documentElement.classList.toggle("dark", t === "dark");
app.onhostcontextchanged = (ctx) => applyTheme(ctx.theme);
await app.connect();
applyTheme(app.getHostContext()?.theme);
```
Keep colors in CSS custom props with a `:root.dark {}` override block and set `color-scheme: light | dark` so native form controls follow.
---
## Server → Widget (progress)
For long-running operations, emit progress notifications. The client sends a `progressToken` in the request's `_meta`; the server emits against it.
```typescript
// In the tool handler
async ({ query }, extra) => {
const token = extra._meta?.progressToken;
for (let i = 0; i < steps.length; i++) {
if (token !== undefined) {
await extra.sendNotification({
method: "notifications/progress",
params: { progressToken: token, progress: i, total: steps.length, message: steps[i].name },
});
}
await steps[i].run();
}
return { content: [{ type: "text", text: "Complete" }] };
}
```
No `{ notify }` destructure — `extra` is `RequestHandlerExtra`; progress goes through `sendNotification`.
---
## Lifecycle
1. Claude calls a tool with `_meta.ui.resourceUri` declared
2. Host fetches the resource (your HTML) and renders it in an iframe
3. Widget script runs, sets handlers, calls `await app.connect()`
4. Host pipes the tool's return value → `ontoolresult` fires
5. Widget renders, user interacts
6. Widget calls `sendMessage` / `updateModelContext` / `callServerTool` as needed
7. Widget persists until conversation context moves on — subsequent calls to the same tool reuse the iframe and fire `ontoolresult` again
There's no explicit "submit and close" — the widget is a long-lived surface.
---
## Sandbox & CSP gotchas
The iframe runs under both an HTML `sandbox` attribute **and** a restrictive Content-Security-Policy. The practical effect is that almost nothing external is allowed — widgets should be self-contained.
| Symptom | Cause | Fix |
|---|---|---|
| Widget is a blank rectangle, nothing renders | CDN `import` of ext-apps blocked (transitive SDK fetches) | **Inline** the `ext-apps/app-with-deps` bundle — see `iframe-sandbox.md` |
| Widget renders but JS doesn't run | Inline event handlers blocked | Use `addEventListener` — never `onclick="..."` in HTML |
| `eval` / `new Function` errors | Script-src restriction | Don't use them; use JSON.parse for data |
| `fetch()` to your API fails | Cross-origin blocked | Route through `app.callServerTool()` instead |
| External CSS doesn't load | `style-src` restriction | Inline styles in a `<style>` tag |
| Fonts don't load | `font-src` restriction | Use system fonts (`font: 14px system-ui`) |
| External `<img src>` broken | CSP `img-src` + referrer hotlink blocking | Fetch server-side, inline as `data:` URL in the tool result payload |
| `window.open()` does nothing | Sandbox lacks `allow-popups` | Use `app.openLink({url})` |
| `<a target="_blank">` does nothing | Same | Intercept click → `preventDefault()``app.openLink` |
| Edited HTML doesn't appear in Desktop | Desktop caches UI resources | Fully quit (⌘Q) + relaunch, not just window-close |
When in doubt, open the **iframe's own** devtools console (not the main app's) — CSP violations log there. See `iframe-sandbox.md` for the bundle-inlining pattern.

View File

@@ -0,0 +1,149 @@
# Iframe sandbox constraints
MCP-app widgets run inside a sandboxed `<iframe>` in the host (Claude Desktop,
claude.ai). The sandbox and CSP attributes lock down what the widget can do.
Every item below was observed failing with a silent blank iframe until the
fix was applied — the error only appears in the iframe's own devtools console,
not the host's.
---
## Problem → fix table
| Symptom | Root cause | Fix |
|---|---|---|
| Widget renders as blank rectangle, no error | CSP `script-src` blocks esm.sh fetching transitive `@modelcontextprotocol/sdk` deps | Inline the `ext-apps/app-with-deps` bundle into the HTML |
| `window.open()` does nothing | Sandbox lacks `allow-popups` | Use `app.openLink({ url })` |
| `<a target="_blank">` does nothing | Same | `e.preventDefault()` + `app.openLink({ url })` on click |
| External `<img src>` broken | CSP `img-src` + referrer hotlink blocking | Fetch server-side, ship as `data:` URL in the tool result payload |
| Widget edits don't appear after server restart | Host caches UI resources | Fully quit the host (⌘Q / Alt+F4) and relaunch |
| Top-level `await` throws | Older iframe contexts | Wrap module body in an async IIFE |
---
## Inlining the ext-apps bundle
`@modelcontextprotocol/ext-apps` ships a self-contained browser build at the
`app-with-deps` export (~300KB). It's minified ESM ending in `export{…}`; to
use it from an inline `<script type="module">` block, rewrite the export
statement into a global assignment at build time:
```ts
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const bundle = readFileSync(
require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"),
"utf8",
).replace(/export\{([^}]+)\};?\s*$/, (_, body) =>
"globalThis.ExtApps={" +
body.split(",").map((pair) => {
const [local, exported] = pair.split(" as ").map((s) => s.trim());
return `${exported ?? local}:${local}`;
}).join(",") + "};",
);
const widgetHtml = readFileSync("./widgets/widget.html", "utf8")
.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);
```
Widget side:
```html
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "…", version: "…" }, {});
// …
})();
</script>
```
The `() => bundle` replacer form (rather than a bare string) is important —
`String.replace` interprets `$…` sequences in a string replacement, and the
minified bundle is full of them.
---
## Outbound links
```js
// ✗ blocked
window.open(url, "_blank");
// ✗ blocked
<a href="…" target="_blank"></a>
// ✓ host-mediated
await app.openLink({ url });
```
Intercept anchor clicks:
```js
el.addEventListener("click", (e) => {
e.preventDefault();
app.openLink({ url: el.href });
});
```
---
## External images
CSP `img-src` defaults (plus many CDN referrer policies) block
`<img src="https://external-cdn/…">` from loading. Inline them server-side in
the tool handler:
```ts
async function toDataUrl(url: string): Promise<string | undefined> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) return undefined;
const buf = Buffer.from(await res.arrayBuffer());
const mime = res.headers.get("content-type") ?? "image/jpeg";
return `data:${mime};base64,${buf.toString("base64")}`;
} catch {
return undefined;
}
}
// in the tool handler
const inlined = await Promise.all(
items.map(async (it) =>
it.thumb ? { ...it, thumb: await toDataUrl(it.thumb) ?? it.thumb } : it,
),
);
```
Add `referrerpolicy="no-referrer"` on the `<img>` as a fallback for any URL
that survives un-inlined.
---
## Dark mode
```js
const applyTheme = (theme) =>
document.documentElement.classList.toggle("dark", theme === "dark");
app.onhostcontextchanged = (ctx) => applyTheme(ctx.theme);
await app.connect();
applyTheme(app.getHostContext()?.theme);
```
```css
:root { --ink:#0f1111; --bg:#fff; color-scheme:light; }
:root.dark { --ink:#e6e6e6; --bg:#1f2428; color-scheme:dark; }
:root.dark .thumb { mix-blend-mode: normal; } /* multiply → images vanish in dark */
```
---
## Debugging
The iframe has its own console. In Claude Desktop, open DevTools (View → Toggle
Developer Tools), then switch the context dropdown (top-left of the Console
tab) from "top" to the widget's iframe. CSP violations, uncaught exceptions,
and import errors all surface there — the host's main console stays silent.

View File

@@ -0,0 +1,249 @@
# Widget Templates
Minimal HTML scaffolds for the common widget shapes. Copy, fill in, ship.
All templates inline the `App` class from `@modelcontextprotocol/ext-apps` at build time — the iframe's CSP blocks CDN script imports. They're intentionally framework-free; widgets are small enough that React/Vue hydration cost usually isn't worth it.
---
## Serving widget HTML
Widgets are static HTML with one placeholder: `/*__EXT_APPS_BUNDLE__*/` gets replaced at server startup with the `ext-apps/app-with-deps` bundle (rewritten to expose `globalThis.ExtApps`).
```typescript
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
const require = createRequire(import.meta.url);
const bundle = readFileSync(
require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"), "utf8",
).replace(/export\{([^}]+)\};?\s*$/, (_, body) =>
"globalThis.ExtApps={" +
body.split(",").map((p) => {
const [local, exported] = p.split(" as ").map((s) => s.trim());
return `${exported ?? local}:${local}`;
}).join(",") + "};",
);
const pickerHtml = readFileSync("./widgets/picker.html", "utf8")
.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);
registerAppResource(server, "Picker", "ui://widgets/picker.html", {},
async () => ({
contents: [{ uri: "ui://widgets/picker.html", mimeType: RESOURCE_MIME_TYPE, text: pickerHtml }],
}),
);
```
Bundle once per server startup (or at build time); reuse the `bundle` string across all widget templates.
---
## Picker (single-select list)
```html
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 0; }
ul { list-style: none; padding: 0; margin: 0; max-height: 280px; overflow-y: auto; }
li { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid #eee; }
li:hover { background: #f5f5f5; }
.sub { color: #666; font-size: 12px; }
</style>
<ul id="list"></ul>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "Picker", version: "1.0.0" }, {});
const ul = document.getElementById("list");
app.ontoolresult = ({ content }) => {
const { items } = JSON.parse(content[0].text);
ul.innerHTML = "";
for (const it of items) {
const li = document.createElement("li");
li.innerHTML = `<div>${it.label}</div><div class="sub">${it.sub ?? ""}</div>`;
li.addEventListener("click", () => {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected: ${it.id}` }],
});
});
ul.append(li);
}
};
await app.connect();
})();
</script>
```
**Tool returns:** `{ content: [{ type: "text", text: JSON.stringify({ items: [{ id, label, sub? }] }) }] }`
---
## Confirm dialog
```html
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 16px; }
.actions { display: flex; gap: 8px; margin-top: 16px; }
button { padding: 8px 16px; cursor: pointer; }
.danger { background: #d33; color: white; border: none; }
</style>
<p id="msg"></p>
<div class="actions">
<button id="cancel">Cancel</button>
<button id="confirm" class="danger">Confirm</button>
</div>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "Confirm", version: "1.0.0" }, {});
app.ontoolresult = ({ content }) => {
const { message, confirmLabel } = JSON.parse(content[0].text);
document.getElementById("msg").textContent = message;
if (confirmLabel) document.getElementById("confirm").textContent = confirmLabel;
};
await app.connect();
document.getElementById("confirm").addEventListener("click", () => {
app.sendMessage({ role: "user", content: [{ type: "text", text: "Confirmed." }] });
});
document.getElementById("cancel").addEventListener("click", () => {
app.sendMessage({ role: "user", content: [{ type: "text", text: "Cancelled." }] });
});
})();
</script>
```
**Tool returns:** `{ content: [{ type: "text", text: JSON.stringify({ message, confirmLabel? }) }] }`
**Note:** For simple confirmation, prefer **elicitation** over a widget — see `../build-mcp-server/references/elicitation.md`. Use this widget when you need custom styling or context beyond what a native form offers.
---
## Progress (long-running)
```html
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 16px; }
.bar { height: 8px; background: #eee; border-radius: 4px; overflow: hidden; }
.fill { height: 100%; background: #2a7; transition: width 200ms; }
</style>
<p id="label">Starting…</p>
<div class="bar"><div id="fill" class="fill" style="width:0%"></div></div>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "Progress", version: "1.0.0" }, {});
const label = document.getElementById("label");
const fill = document.getElementById("fill");
// The tool result fires when the job completes — intermediate updates
// arrive via the same handler if the server streams them
app.ontoolresult = ({ content }) => {
const state = JSON.parse(content[0].text);
if (state.progress !== undefined) {
label.textContent = state.message ?? `${state.progress}/${state.total}`;
fill.style.width = `${(state.progress / state.total) * 100}%`;
}
if (state.done) {
label.textContent = "Complete";
fill.style.width = "100%";
}
};
await app.connect();
})();
</script>
```
Server side, emit progress via `extra.sendNotification({ method: "notifications/progress", ... })` — see `apps-sdk-messages.md`.
---
## Display-only (chart / preview)
Display widgets don't call `sendMessage` — they render and sit there. The tool should return a text summary **alongside** the widget so Claude can keep reasoning while the user sees the visual:
```typescript
registerAppTool(server, "show_chart", {
description: "Render a revenue chart",
inputSchema: { range: z.enum(["week", "month", "year"]) },
_meta: { ui: { resourceUri: "ui://widgets/chart.html" } },
}, async ({ range }) => {
const data = await fetchRevenue(range);
return {
content: [{
type: "text",
text: `Revenue is up ${data.change}% over the ${range}. Chart rendered.\n\n` +
JSON.stringify(data.points),
}],
};
});
```
```html
<!doctype html>
<meta charset="utf-8" />
<style>body { font: 14px system-ui; margin: 12px; }</style>
<canvas id="chart" width="400" height="200"></canvas>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "Chart", version: "1.0.0" }, {});
app.ontoolresult = ({ content }) => {
// Parse the JSON points from the text content (after the summary line)
const text = content[0].text;
const jsonStart = text.indexOf("\n\n") + 2;
const points = JSON.parse(text.slice(jsonStart));
drawChart(document.getElementById("chart"), points);
};
await app.connect();
function drawChart(canvas, points) { /* ... */ }
})();
</script>
```
---
## Carousel (multi-item display with actions)
For presenting multiple items (product picks, search results) in a horizontal scroll rail. Patterns that tested well:
- **Skip nav chevrons** — users know how to scroll. `scroll-snap-type` can cause a few-px-off-flush initial render; omit it and `scrollLeft = 0` after rendering.
- **Layout-fork by item count** — `items.length === 1` → detail/PDP layout, `> 1` → carousel. Handle in widget JS, keep the tool schema flat.
- **Put Claude's reasoning in each item** — a `note` field rendered as a small callout on the card gives users the "why" inline.
- **Silent state via `updateModelContext`** — cart/selection changes should inform Claude without spamming the chat. Reserve `sendMessage` for terminal actions ("checkout", "done").
- **Outbound links via `app.openLink`** — `window.open` and `<a target="_blank">` are blocked by the sandbox.
```html
<style>
.rail { display: flex; gap: 10px; overflow-x: auto; padding: 12px; scrollbar-width: none; }
.rail::-webkit-scrollbar { display: none; }
.card { flex: 0 0 220px; border: 1px solid #ddd; border-radius: 6px; padding: 10px; }
.thumb-box { aspect-ratio: 1 / 1; display: grid; place-items: center; background: #f7f8f8; }
.thumb { max-width: 100%; max-height: 100%; object-fit: contain; }
.note { font-size: 12px; color: #666; border-left: 3px solid orange; padding: 2px 8px; margin: 8px 0; }
</style>
<div class="rail" id="rail"></div>
```
**Images:** the iframe CSP blocks remote `img-src`. Fetch thumbnails server-side in the tool handler, embed as `data:` URLs in the JSON payload, and render from those. Add `referrerpolicy="no-referrer"` as a fallback.