mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-03-20 23:43:07 +00:00
add(plugin): mcp-server-dev — skills for building MCP servers
Three skills guiding developers through MCP server design: - build-mcp-server: entry-point decision guide (remote HTTP vs MCPB vs local) - build-mcp-app: interactive UI widgets rendered in chat - build-mcpb: bundled local servers with runtime Includes reference files for scaffolds, tool design, auth (DCR/CIMD), widget templates, manifest schema, and local security hardening.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
# Apps SDK — Widget ↔ Host Message Protocol
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Widget → host
|
||||
|
||||
### `submit(result)`
|
||||
|
||||
Ends the interaction. `result` is returned to Claude as the tool's output (serialized to JSON). The iframe is torn down after this fires.
|
||||
|
||||
```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);
|
||||
});
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```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` });
|
||||
}
|
||||
return { content: [...] };
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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."
|
||||
|
||||
---
|
||||
|
||||
## CSP gotchas
|
||||
|
||||
The iframe sandbox enforces a strict Content Security Policy. Common failures:
|
||||
|
||||
| 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 |
|
||||
| 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.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Widget Templates
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## The render helper
|
||||
|
||||
Ten lines of string templating. Good enough for almost every case.
|
||||
|
||||
```typescript
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const TEMPLATE_DIR = join(import.meta.dirname, "../widgets");
|
||||
|
||||
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"),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Every template below hydrates from `<script id="data">__DATA__</script>`. The `<` escape prevents `</script>` injection.
|
||||
|
||||
---
|
||||
|
||||
## Picker (single-select list)
|
||||
|
||||
```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; }
|
||||
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">
|
||||
import { submit } from "https://esm.sh/@modelcontextprotocol/apps-sdk";
|
||||
const { items } = JSON.parse(document.getElementById("data").textContent);
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
**Data shape:** `{ items: [{ id, label, sub? }] }`
|
||||
**Result shape:** `{ id }`
|
||||
|
||||
---
|
||||
|
||||
## Confirm dialog
|
||||
|
||||
```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; }
|
||||
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">
|
||||
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 });
|
||||
</script>
|
||||
```
|
||||
|
||||
**Data shape:** `{ message, confirmLabel? }`
|
||||
**Result shape:** `{ confirmed: boolean }`
|
||||
|
||||
---
|
||||
|
||||
## Progress (long-running)
|
||||
|
||||
```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; }
|
||||
.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">
|
||||
import { submit, onMessage } from "https://esm.sh/@modelcontextprotocol/apps-sdk";
|
||||
const { jobId } = JSON.parse(document.getElementById("data").textContent);
|
||||
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}%`;
|
||||
}
|
||||
if (msg.type === "done") submit(msg.result);
|
||||
});
|
||||
</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.
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
```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) } },
|
||||
],
|
||||
};
|
||||
```
|
||||
Reference in New Issue
Block a user