mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-03-21 11:53:08 +00:00
fix(plugin): mcp-server-dev — correct APIs against spec, add missing primitives
Corrects fabricated/deprecated APIs: ext-apps App class model (not embedded resources), real MCPB v0.4 manifest (no permissions block exists), registerTool (not server.tool), @anthropic-ai/mcpb package name, CIMD preferred over DCR. Adds missing spec coverage: resources, prompts, elicitation (with capability check + fallback), sampling, roots, tool annotations, structured output, instructions field, progress/cancellation.
This commit is contained in:
@@ -28,6 +28,24 @@ 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)
|
||||
@@ -59,117 +77,178 @@ For MCPB packaging mechanics, defer to the **`build-mcpb`** skill. Everything be
|
||||
|
||||
## How widgets attach to tools
|
||||
|
||||
A tool declares a widget by returning an **embedded resource** in its result alongside (or instead of) text content. The resource's `mimeType` tells the host to render it, and the `text` field carries the widget's HTML.
|
||||
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
|
||||
server.tool(
|
||||
"pick_contact",
|
||||
"Open an interactive contact picker. The user selects one contact; its ID is returned.",
|
||||
{
|
||||
filter: z.string().optional().describe("Optional name/email prefix filter"),
|
||||
},
|
||||
async ({ filter }) => {
|
||||
const contacts = await listContacts(filter);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "ui://widgets/contact-picker",
|
||||
mimeType: "text/html+skybridge",
|
||||
text: renderContactPicker(contacts),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
```
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
|
||||
from "@modelcontextprotocol/ext-apps/server";
|
||||
import { z } from "zod";
|
||||
|
||||
The host renders the resource in a sandboxed iframe. The widget posts a message back when the user picks something; the host injects that result into the conversation so Claude can continue.
|
||||
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) }] };
|
||||
});
|
||||
|
||||
## Widget runtime contract
|
||||
|
||||
Widgets run in a sandboxed iframe. They talk to the host via `window.parent.postMessage` with a small set of message types. The exact envelope is host-defined — the MCP apps SDK wraps it so you don't hand-roll `postMessage`.
|
||||
|
||||
**What widgets can do:**
|
||||
- Render arbitrary HTML/CSS/JS (sandboxed — no same-origin access to the host page)
|
||||
- Receive an initial `data` payload from the tool result
|
||||
- Post a **result** back (ends the interaction, value flows to Claude)
|
||||
- Post **progress** updates (for long-running widgets)
|
||||
- Request the host **call another tool** on the same server
|
||||
|
||||
**What widgets cannot do:**
|
||||
- Access the host page's DOM, cookies, or storage
|
||||
- Make network calls to origins other than your MCP server (CSP-restricted)
|
||||
- Persist state across renders (each tool call is a fresh iframe)
|
||||
|
||||
Keep widgets **small and single-purpose**. A picker picks. A form submits. Don't build a whole sub-app inside the iframe — split it into multiple tools with focused widgets.
|
||||
|
||||
---
|
||||
|
||||
## Scaffold: minimal form widget
|
||||
|
||||
**Tool (TypeScript SDK):**
|
||||
|
||||
```typescript
|
||||
import { renderWidget } from "./widgets";
|
||||
|
||||
server.tool(
|
||||
"create_ticket",
|
||||
"Open a form to create a support ticket. User fills in title, priority, and description.",
|
||||
// 2. The resource — serves the HTML
|
||||
registerAppResource(
|
||||
server,
|
||||
"Contact Picker",
|
||||
"ui://widgets/contact-picker.html",
|
||||
{},
|
||||
async () => ({
|
||||
content: [
|
||||
{
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "ui://widgets/create-ticket",
|
||||
mimeType: "text/html+skybridge",
|
||||
text: renderWidget("create-ticket", {
|
||||
priorities: ["low", "medium", "high", "urgent"],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
contents: [{
|
||||
uri: "ui://widgets/contact-picker.html",
|
||||
mimeType: RESOURCE_MIME_TYPE,
|
||||
text: pickerHtml, // your HTML string
|
||||
}],
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
**Widget template (`widgets/create-ticket.html`):**
|
||||
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">
|
||||
import { App } from "https://esm.sh/@modelcontextprotocol/ext-apps@1.2.2";
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
| 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 |
|
||||
|
||||
`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.
|
||||
|
||||
**What widgets cannot do:**
|
||||
- Access the host page's DOM, cookies, or storage
|
||||
- Make network calls to arbitrary origins (CSP-restricted — route through `callServerTool`)
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
**Server (`src/server.ts`):**
|
||||
|
||||
```typescript
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
|
||||
from "@modelcontextprotocol/ext-apps/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { z } from "zod";
|
||||
|
||||
const server = new McpServer({ name: "contact-picker", version: "1.0.0" });
|
||||
|
||||
const pickerHtml = readFileSync("./widgets/picker.html", "utf8");
|
||||
|
||||
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 }],
|
||||
}),
|
||||
);
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
```
|
||||
|
||||
**Widget (`widgets/picker.html`):**
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body { font: 14px system-ui; margin: 12px; }
|
||||
label { display: block; margin-top: 8px; font-weight: 500; }
|
||||
input, select, textarea { width: 100%; padding: 6px; margin-top: 2px; }
|
||||
button { margin-top: 12px; padding: 8px 16px; }
|
||||
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>
|
||||
<form id="f">
|
||||
<label>Title <input name="title" required /></label>
|
||||
<label>Priority
|
||||
<select name="priority">
|
||||
{{#each priorities}}<option>{{this}}</option>{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Description <textarea name="description" rows="4"></textarea></label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
<ul id="list"></ul>
|
||||
<script type="module">
|
||||
import { submit } from "https://esm.sh/@modelcontextprotocol/apps-sdk";
|
||||
document.getElementById("f").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(e.target));
|
||||
submit(data); // → flows back to Claude as the tool's result
|
||||
});
|
||||
import { App } from "https://esm.sh/@modelcontextprotocol/ext-apps@1.2.2";
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
`renderWidget` is a ~10-line template function — see `references/widget-templates.md`.
|
||||
See `references/widget-templates.md` for more widget shapes.
|
||||
|
||||
---
|
||||
|
||||
@@ -179,7 +258,7 @@ server.tool(
|
||||
|
||||
**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 fall back to showing the resource as a link or raw text. Your tool should still return something sensible in `content[].text` alongside the widget for that case.
|
||||
**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.
|
||||
|
||||
@@ -187,7 +266,7 @@ server.tool(
|
||||
|
||||
## Testing
|
||||
|
||||
- **Local:** point Claude desktop's MCP config at `http://localhost:3000/mcp`, trigger the tool, check the widget renders and submits.
|
||||
- **Local:** point Claude desktop's MCP config at your server, trigger the tool, check the widget renders and `sendMessage` flows back into the chat.
|
||||
- **Host fallback:** disable the apps surface (or use a host without it) and confirm the tool degrades gracefully.
|
||||
- **CSP:** open browser devtools on the iframe — CSP violations are the #1 reason widgets silently fail.
|
||||
|
||||
@@ -195,5 +274,5 @@ server.tool(
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/widget-templates.md` — reusable HTML scaffolds for form / picker / confirm / progress
|
||||
- `references/apps-sdk-messages.md` — the `postMessage` protocol between widget and host
|
||||
- `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
|
||||
|
||||
@@ -1,99 +1,120 @@
|
||||
# Apps SDK — Widget ↔ Host Message Protocol
|
||||
# ext-apps messaging — widget ↔ host ↔ server
|
||||
|
||||
Widgets communicate with the MCP host through `window.parent.postMessage`. The apps SDK wraps this in helpers so you rarely touch the raw envelope, but knowing the shape helps when debugging.
|
||||
The `@modelcontextprotocol/ext-apps` package provides the `App` class (browser side) and `registerAppTool`/`registerAppResource` helpers (server side). Messaging is bidirectional and persistent.
|
||||
|
||||
---
|
||||
|
||||
## Widget → host
|
||||
## Widget → Host
|
||||
|
||||
### `submit(result)`
|
||||
### `app.sendMessage({ role, content })`
|
||||
|
||||
Ends the interaction. `result` is returned to Claude as the tool's output (serialized to JSON). The iframe is torn down after this fires.
|
||||
Inject a visible message into the conversation. This is how user actions become conversation turns.
|
||||
|
||||
```js
|
||||
import { submit } from "@modelcontextprotocol/apps-sdk";
|
||||
submit({ id: "usr_abc123", action: "selected" });
|
||||
```
|
||||
|
||||
Raw envelope:
|
||||
```json
|
||||
{ "type": "mcp:result", "result": { "id": "usr_abc123", "action": "selected" } }
|
||||
```
|
||||
|
||||
### `callTool(name, args)`
|
||||
|
||||
Ask the host to invoke **another tool on the same server** and return the result to the widget. Use for widgets that need to fetch more data after initial render (pagination, drill-down).
|
||||
|
||||
```js
|
||||
import { callTool } from "@modelcontextprotocol/apps-sdk";
|
||||
const page2 = await callTool("list_items", { offset: 20, limit: 20 });
|
||||
```
|
||||
|
||||
Round-trips through the host, so it's slower than embedding all data upfront. Only use when the full dataset is too large to ship in the initial payload.
|
||||
|
||||
### `resize(height)`
|
||||
|
||||
Tell the host the widget's content height so the iframe can be sized. The SDK auto-calls this on load via `ResizeObserver`; call manually only if your content height changes after an async operation.
|
||||
|
||||
---
|
||||
|
||||
## Host → widget
|
||||
|
||||
### Initial data
|
||||
|
||||
The widget's initial payload is **not** a message — it's baked into the HTML by the server (the `__DATA__` substitution pattern). This avoids a round-trip and works even if the message channel is slow to establish.
|
||||
|
||||
### `onMessage(handler)`
|
||||
|
||||
Subscribe to pushes from the server. Used by progress widgets and anything live-updating.
|
||||
|
||||
```js
|
||||
import { onMessage } from "@modelcontextprotocol/apps-sdk";
|
||||
onMessage((msg) => {
|
||||
if (msg.type === "progress") updateBar(msg.percent);
|
||||
app.sendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "User selected order #1234" }],
|
||||
});
|
||||
```
|
||||
|
||||
Server side (TypeScript SDK), push via the notification stream keyed to the tool call's request context. The SDK exposes this as a `notify` callback on the tool handler:
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
server.tool("long_job", "...", schema, async (args, { notify }) => {
|
||||
for (let i = 0; i <= 100; i += 10) {
|
||||
await step();
|
||||
notify({ type: "progress", percent: i, label: `Step ${i / 10}/10` });
|
||||
// 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: [...] };
|
||||
});
|
||||
return { content: [{ type: "text", text: "Complete" }] };
|
||||
}
|
||||
```
|
||||
|
||||
No `{ notify }` destructure — `extra` is `RequestHandlerExtra`; progress goes through `sendNotification`.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```
|
||||
1. Claude calls tool
|
||||
2. Server returns content with embedded resource (mimeType: text/html+skybridge)
|
||||
3. Host renders resource text in sandboxed iframe
|
||||
4. Widget hydrates from inline __DATA__
|
||||
5. (optional) Widget ↔ host messages: callTool, progress pushes
|
||||
6. Widget calls submit(result)
|
||||
7. Host tears down iframe, injects result into conversation
|
||||
8. Claude continues with the result
|
||||
```
|
||||
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
|
||||
|
||||
If step 6 never happens (user closes the widget, host times out), the tool call resolves with a cancellation result. Your tool description should account for this — "Returns the selected ID, or null if the user cancels."
|
||||
There's no explicit "submit and close" — the widget is a long-lived surface.
|
||||
|
||||
---
|
||||
|
||||
## CSP gotchas
|
||||
|
||||
The iframe sandbox enforces a strict Content Security Policy. Common failures:
|
||||
The iframe runs under a restrictive Content-Security-Policy:
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Widget renders but JS doesn't run | Inline script blocked | Use `<script type="module">` with SDK import; avoid inline event handlers in HTML attributes |
|
||||
| `fetch()` fails silently | Cross-origin blocked | Route through `callTool()` instead |
|
||||
| External CSS doesn't load | `style-src` restriction | Inline your styles in a `<style>` tag |
|
||||
| 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 |
|
||||
| External scripts fail | CDN not allowlisted | `esm.sh` is safe; avoid others |
|
||||
| `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`) |
|
||||
|
||||
When in doubt, open the iframe's devtools console — CSP violations log there.
|
||||
|
||||
@@ -2,31 +2,27 @@
|
||||
|
||||
Minimal HTML scaffolds for the common widget shapes. Copy, fill in, ship.
|
||||
|
||||
All templates assume the apps-SDK helper is available at an ESM CDN. They're intentionally framework-free — widgets render in a fresh iframe each time, so React/Vue hydration cost usually isn't worth it for something this small.
|
||||
All templates use the `App` class from `@modelcontextprotocol/ext-apps` via ESM CDN. They're intentionally framework-free — widgets are small enough that React/Vue hydration cost usually isn't worth it.
|
||||
|
||||
---
|
||||
|
||||
## The render helper
|
||||
## Serving widget HTML
|
||||
|
||||
Ten lines of string templating. Good enough for almost every case.
|
||||
Widgets are static HTML — data arrives at runtime via `ontoolresult`, not baked in. Store each widget as a string constant or read from disk:
|
||||
|
||||
```typescript
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
|
||||
|
||||
const TEMPLATE_DIR = join(import.meta.dirname, "../widgets");
|
||||
const pickerHtml = readFileSync("./widgets/picker.html", "utf8");
|
||||
|
||||
export function renderWidget(name: string, data: unknown): string {
|
||||
const tpl = readFileSync(join(TEMPLATE_DIR, `${name}.html`), "utf8");
|
||||
return tpl.replace(
|
||||
"__DATA__",
|
||||
JSON.stringify(data).replace(/</g, "\\u003c"),
|
||||
);
|
||||
}
|
||||
registerAppResource(server, "Picker", "ui://widgets/picker.html", {},
|
||||
async () => ({
|
||||
contents: [{ uri: "ui://widgets/picker.html", mimeType: RESOURCE_MIME_TYPE, text: pickerHtml }],
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Every template below hydrates from `<script id="data">__DATA__</script>`. The `<` escape prevents `</script>` injection.
|
||||
|
||||
---
|
||||
|
||||
## Picker (single-select list)
|
||||
@@ -34,7 +30,6 @@ Every template below hydrates from `<script id="data">__DATA__</script>`. The `<
|
||||
```html
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<script id="data" type="application/json">__DATA__</script>
|
||||
<style>
|
||||
body { font: 14px system-ui; margin: 0; }
|
||||
ul { list-style: none; padding: 0; margin: 0; max-height: 280px; overflow-y: auto; }
|
||||
@@ -44,20 +39,32 @@ Every template below hydrates from `<script id="data">__DATA__</script>`. The `<
|
||||
</style>
|
||||
<ul id="list"></ul>
|
||||
<script type="module">
|
||||
import { submit } from "https://esm.sh/@modelcontextprotocol/apps-sdk";
|
||||
const { items } = JSON.parse(document.getElementById("data").textContent);
|
||||
import { App } from "https://esm.sh/@modelcontextprotocol/ext-apps@1.2.2";
|
||||
|
||||
const app = new App({ name: "Picker", version: "1.0.0" }, {});
|
||||
const ul = document.getElementById("list");
|
||||
for (const it of items) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<div>${it.label}</div><div class="sub">${it.sub ?? ""}</div>`;
|
||||
li.onclick = () => submit({ id: it.id });
|
||||
ul.append(li);
|
||||
}
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
**Data shape:** `{ items: [{ id, label, sub? }] }`
|
||||
**Result shape:** `{ id }`
|
||||
**Tool returns:** `{ content: [{ type: "text", text: JSON.stringify({ items: [{ id, label, sub? }] }) }] }`
|
||||
|
||||
---
|
||||
|
||||
@@ -66,7 +73,6 @@ Every template below hydrates from `<script id="data">__DATA__</script>`. The `<
|
||||
```html
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<script id="data" type="application/json">__DATA__</script>
|
||||
<style>
|
||||
body { font: 14px system-ui; margin: 16px; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
@@ -79,17 +85,30 @@ Every template below hydrates from `<script id="data">__DATA__</script>`. The `<
|
||||
<button id="confirm" class="danger">Confirm</button>
|
||||
</div>
|
||||
<script type="module">
|
||||
import { submit } from "https://esm.sh/@modelcontextprotocol/apps-sdk";
|
||||
const { message, confirmLabel } = JSON.parse(document.getElementById("data").textContent);
|
||||
document.getElementById("msg").textContent = message;
|
||||
if (confirmLabel) document.getElementById("confirm").textContent = confirmLabel;
|
||||
document.getElementById("confirm").onclick = () => submit({ confirmed: true });
|
||||
document.getElementById("cancel").onclick = () => submit({ confirmed: false });
|
||||
import { App } from "https://esm.sh/@modelcontextprotocol/ext-apps@1.2.2";
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
**Data shape:** `{ message, confirmLabel? }`
|
||||
**Result shape:** `{ confirmed: boolean }`
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -98,7 +117,6 @@ Every template below hydrates from `<script id="data">__DATA__</script>`. The `<
|
||||
```html
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<script id="data" type="application/json">__DATA__</script>
|
||||
<style>
|
||||
body { font: 14px system-ui; margin: 16px; }
|
||||
.bar { height: 8px; background: #eee; border-radius: 4px; overflow: hidden; }
|
||||
@@ -107,34 +125,75 @@ Every template below hydrates from `<script id="data">__DATA__</script>`. The `<
|
||||
<p id="label">Starting…</p>
|
||||
<div class="bar"><div id="fill" class="fill" style="width:0%"></div></div>
|
||||
<script type="module">
|
||||
import { submit, onMessage } from "https://esm.sh/@modelcontextprotocol/apps-sdk";
|
||||
const { jobId } = JSON.parse(document.getElementById("data").textContent);
|
||||
import { App } from "https://esm.sh/@modelcontextprotocol/ext-apps@1.2.2";
|
||||
|
||||
const app = new App({ name: "Progress", version: "1.0.0" }, {});
|
||||
const label = document.getElementById("label");
|
||||
const fill = document.getElementById("fill");
|
||||
|
||||
onMessage((msg) => {
|
||||
if (msg.type === "progress") {
|
||||
label.textContent = msg.label;
|
||||
fill.style.width = `${msg.percent}%`;
|
||||
// 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 (msg.type === "done") submit(msg.result);
|
||||
});
|
||||
if (state.done) {
|
||||
label.textContent = "Complete";
|
||||
fill.style.width = "100%";
|
||||
}
|
||||
};
|
||||
|
||||
await app.connect();
|
||||
</script>
|
||||
```
|
||||
|
||||
The server pushes updates via the transport's notification channel targeting this widget's session. See `apps-sdk-messages.md` for the server-side push.
|
||||
Server side, emit progress via `extra.sendNotification({ method: "notifications/progress", ... })` — see `apps-sdk-messages.md`.
|
||||
|
||||
---
|
||||
|
||||
## Display-only (chart / preview)
|
||||
|
||||
Display widgets don't need `submit()` — they render and sit there. Return a text summary **alongside** the widget so Claude can keep reasoning:
|
||||
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
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: "Revenue is up 12% MoM. Chart rendered below." },
|
||||
{ type: "resource", resource: { uri: "ui://widgets/chart", mimeType: "text/html+skybridge", text: renderWidget("chart", data) } },
|
||||
],
|
||||
};
|
||||
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">
|
||||
import { App } from "https://esm.sh/@modelcontextprotocol/ext-apps@1.2.2";
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user