mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-03-19 11:13: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>
|
||||
```
|
||||
|
||||
@@ -38,14 +38,16 @@ This determines the tool-design pattern — see Phase 3.
|
||||
- **Under ~15 actions** → one tool per action
|
||||
- **Dozens to hundreds of actions** (e.g. wrapping a large API surface) → search + execute pattern
|
||||
|
||||
### 4. Does it need interactive UI in the chat?
|
||||
### 4. Does a tool need mid-call user input or rich display?
|
||||
|
||||
Forms, pickers, dashboards, confirmation dialogs rendered inline in the conversation → **MCP app** (adds UI resources on top of a standard server).
|
||||
- **Simple structured input** (pick from list, enter a value, confirm) → **Elicitation** — spec-native, zero UI code. *Host support is rolling out* (Claude Code ≥2.1.76) — always pair with a capability check and fallback. See `references/elicitation.md`.
|
||||
- **Rich/visual UI** (charts, custom pickers with search, live dashboards) → **MCP app widgets** — iframe-based, needs `@modelcontextprotocol/ext-apps`. See `build-mcp-app` skill.
|
||||
- **Neither** → plain tool returning text/JSON.
|
||||
|
||||
### 5. What auth does the upstream service use?
|
||||
|
||||
- None / API key → straightforward
|
||||
- OAuth 2.0 → you'll need a remote server with DCR (Dynamic Client Registration) or CIMD support; see `references/auth.md`
|
||||
- OAuth 2.0 → you'll need a remote server with CIMD (preferred) or DCR support; see `references/auth.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -67,11 +69,19 @@ A hosted service speaking MCP over streamable HTTP. This is the **recommended pa
|
||||
|
||||
→ Scaffold with `references/remote-http-scaffold.md`
|
||||
|
||||
### Elicitation (structured input, no UI build)
|
||||
|
||||
If a tool just needs the user to confirm, pick an option, or fill a short form, **elicitation** does it with zero UI code. The server sends a flat JSON schema; the host renders a native form. Spec-native, no extra packages.
|
||||
|
||||
**Caveat:** Host support is new (Claude Code shipped it in v2.1.76; Desktop unconfirmed). The SDK throws if the client doesn't advertise the capability. Always check `clientCapabilities.elicitation` first and have a fallback — see `references/elicitation.md` for the canonical pattern. This is the right spec-correct approach; host coverage will catch up.
|
||||
|
||||
Escalate to `build-mcp-app` widgets when you need: nested/complex data, scrollable/searchable lists, visual previews, live updates.
|
||||
|
||||
### MCP app (remote HTTP + interactive UI)
|
||||
|
||||
Same as above, plus **UI resources** — interactive widgets rendered in chat. Forms, file pickers, rich previews, confirmation dialogs. Built once, renders in Claude *and* ChatGPT.
|
||||
Same as above, plus **UI resources** — interactive widgets rendered in chat. Rich pickers with search, charts, live dashboards, visual previews. Built once, renders in Claude *and* ChatGPT.
|
||||
|
||||
**Choose this when** one or more tools benefit from structured user input or rich output that plain text can't handle.
|
||||
**Choose this when** elicitation's flat-form constraints don't fit — you need custom layout, large searchable lists, visual content, or live updates.
|
||||
|
||||
Usually remote, but can be shipped as MCPB if the UI needs to drive a local app.
|
||||
|
||||
@@ -137,7 +147,7 @@ Recommend one of these two. Others exist but these have the best MCP-spec covera
|
||||
| Framework | Language | Use when |
|
||||
|---|---|---|
|
||||
| **Official TypeScript SDK** (`@modelcontextprotocol/sdk`) | TS/JS | Default choice. Best spec coverage, first to get new features. |
|
||||
| **FastMCP 2.0** | Python | User prefers Python, or wrapping a Python library. Decorator-based, very low boilerplate. |
|
||||
| **FastMCP 3.x** (`fastmcp` on PyPI) | Python | User prefers Python, or wrapping a Python library. Decorator-based, very low boilerplate. This is jlowin's package — not the frozen FastMCP 1.0 bundled in the official `mcp` SDK. |
|
||||
|
||||
If the user already has a language/stack in mind, go with it — both produce identical wire protocol.
|
||||
|
||||
@@ -156,6 +166,21 @@ When handing off, restate the design brief in one paragraph so the next skill do
|
||||
|
||||
---
|
||||
|
||||
## Beyond tools — the other primitives
|
||||
|
||||
Tools are one of three server primitives. Most servers start with tools and never need the others, but knowing they exist prevents reinventing wheels:
|
||||
|
||||
| Primitive | Who triggers it | Use when |
|
||||
|---|---|---|
|
||||
| **Resources** | Host app (not Claude) | Exposing docs/files/data as browsable context |
|
||||
| **Prompts** | User (slash command) | Canned workflows ("/summarize-thread") |
|
||||
| **Elicitation** | Server, mid-tool | Asking user for input without building UI |
|
||||
| **Sampling** | Server, mid-tool | Need LLM inference in your tool logic |
|
||||
|
||||
→ `references/resources-and-prompts.md`, `references/elicitation.md`, `references/server-capabilities.md`
|
||||
|
||||
---
|
||||
|
||||
## Quick reference: decision matrix
|
||||
|
||||
| Scenario | Deployment | Tool pattern |
|
||||
@@ -174,4 +199,7 @@ When handing off, restate the design brief in one paragraph so the next skill do
|
||||
|
||||
- `references/remote-http-scaffold.md` — minimal remote server in TS SDK and FastMCP
|
||||
- `references/tool-design.md` — writing tool descriptions and schemas Claude understands well
|
||||
- `references/auth.md` — OAuth, DCR, CIMD, token storage patterns
|
||||
- `references/auth.md` — OAuth, CIMD, DCR, token storage patterns
|
||||
- `references/resources-and-prompts.md` — the two non-tool primitives
|
||||
- `references/elicitation.md` — spec-native user input mid-tool (capability check + fallback)
|
||||
- `references/server-capabilities.md` — instructions, sampling, roots, logging, progress, cancellation
|
||||
|
||||
@@ -17,32 +17,32 @@ if (!apiKey) throw new Error("UPSTREAM_API_KEY not set");
|
||||
|
||||
Works for local stdio, MCPB, and remote servers alike. If this is all you need, stop here.
|
||||
|
||||
### Tier 2: OAuth 2.0 via Dynamic Client Registration (DCR)
|
||||
### Tier 2: OAuth 2.0 via CIMD (preferred per spec 2025-11-25)
|
||||
|
||||
The MCP host (Claude desktop, Claude Code, etc.) discovers your server's OAuth metadata, **registers itself as a client dynamically**, runs the auth-code flow, and stores the token. Your server never sees credentials — it just receives bearer tokens on each request.
|
||||
**Client ID Metadata Document.** The MCP host publishes its client metadata at an HTTPS URL and uses that URL *as* its `client_id`. Your authorization server fetches the document, validates it, and proceeds with the auth-code flow. No registration endpoint, no stored client records.
|
||||
|
||||
This is the **recommended path** for any remote server wrapping an OAuth-protected API.
|
||||
Spec 2025-11-25 promoted CIMD to SHOULD (preferred). Advertise support via `client_id_metadata_document_supported: true` in your OAuth AS metadata.
|
||||
|
||||
**Server responsibilities:**
|
||||
|
||||
1. Serve OAuth Authorization Server Metadata (RFC 8414) at `/.well-known/oauth-authorization-server`
|
||||
1. Serve OAuth Authorization Server Metadata (RFC 8414) at `/.well-known/oauth-authorization-server` with `client_id_metadata_document_supported: true`
|
||||
2. Serve an MCP-protected-resource metadata document pointing at (1)
|
||||
3. Implement (or proxy to) a DCR endpoint that hands out client IDs
|
||||
3. At authorize time: fetch `client_id` as an HTTPS URL, validate the returned client metadata, proceed
|
||||
4. Validate bearer tokens on incoming `/mcp` requests
|
||||
|
||||
Most of this is boilerplate — the SDK has helpers. The real decision is whether you **proxy** to the upstream's OAuth (if they support DCR) or run your own **shim** authorization server that exchanges your tokens for upstream tokens.
|
||||
|
||||
```
|
||||
┌─────────┐ DCR + auth code ┌──────────────┐ upstream OAuth ┌──────────┐
|
||||
│ MCP host│ ──────────────────> │ Your MCP srv │ ─────────────────> │ Upstream │
|
||||
└─────────┘ <── bearer token ── └──────────────┘ <── access token ──└──────────┘
|
||||
┌─────────┐ client_id=https://... ┌──────────────┐ upstream OAuth ┌──────────┐
|
||||
│ MCP host│ ──────────────────────> │ Your MCP srv │ ─────────────────> │ Upstream │
|
||||
└─────────┘ <─── bearer token ───── └──────────────┘ <── access token ──└──────────┘
|
||||
```
|
||||
|
||||
### Tier 3: CIMD (Client ID Metadata Document)
|
||||
### Tier 3: OAuth 2.0 via Dynamic Client Registration (DCR)
|
||||
|
||||
An alternative to DCR for ecosystems that don't want dynamic registration. The host publishes its client metadata at a well-known URL; your server fetches it, validates it, and issues a client credential. Lower friction than DCR for the host, slightly more work for you.
|
||||
**Backward-compat fallback** — spec 2025-11-25 demoted DCR to MAY. The host discovers your `registration_endpoint`, POSTs its metadata to register itself as a client, gets back a `client_id`, then runs the auth-code flow.
|
||||
|
||||
Use CIMD when targeting hosts that advertise CIMD support in their client metadata. Otherwise default to DCR — it's more broadly implemented.
|
||||
Implement DCR if you need to support hosts that haven't moved to CIMD yet. Same server responsibilities as CIMD, but instead of fetching the `client_id` URL you run a registration endpoint that stores client records.
|
||||
|
||||
**Client priority order:** pre-registered → CIMD (if AS advertises `client_id_metadata_document_supported`) → DCR (if AS has `registration_endpoint`) → prompt user.
|
||||
|
||||
---
|
||||
|
||||
@@ -71,3 +71,22 @@ If OAuth is required, lean hard toward remote HTTP. If you *must* ship local + O
|
||||
| Remote, stateless | Nowhere — host sends bearer each request |
|
||||
| Remote, stateful | Session store keyed by MCP session ID (Redis, etc.) |
|
||||
| MCPB / local | OS keychain (`keytar` on Node, `keyring` on Python). **Never plaintext on disk.** |
|
||||
|
||||
---
|
||||
|
||||
## Token audience validation (spec MUST)
|
||||
|
||||
Validating "is this a valid bearer token" isn't enough. The spec requires validating "was this token minted *for this server*" — RFC 8707 audience. A token issued for `api.other-service.com` must be rejected even if the signature checks out.
|
||||
|
||||
**Token passthrough is explicitly forbidden.** Don't accept a token, then forward it upstream. If your server needs to call another service, exchange the token or use its own credentials.
|
||||
|
||||
---
|
||||
|
||||
## SDK helpers — don't hand-roll
|
||||
|
||||
`@modelcontextprotocol/sdk/server/auth` ships:
|
||||
- `mcpAuthRouter()` — Express router for the full OAuth AS surface (metadata, authorize, token)
|
||||
- `bearerAuth` — middleware that validates bearer tokens against your verifier
|
||||
- `proxyProvider` — forward auth to an upstream IdP
|
||||
|
||||
If you're wiring auth from scratch, check these first.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# Elicitation — spec-native user input
|
||||
|
||||
Elicitation lets a server pause mid-tool-call and ask the user for structured input. The client renders a native form (no iframe, no HTML). User fills it, server continues.
|
||||
|
||||
**This is the right answer for simple input.** Widgets (`build-mcp-app`) are for when you need rich UI — charts, searchable lists, visual previews. If you just need a confirmation, a picked option, or a few form fields, elicitation is simpler, spec-native, and works in any compliant host.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Check capability first — support is new
|
||||
|
||||
Host support is very recent:
|
||||
|
||||
| Host | Status |
|
||||
|---|---|
|
||||
| Claude Code | ✅ since v2.1.76 (both `form` and `url` modes) |
|
||||
| Claude Desktop | Unconfirmed — likely not yet or very recent |
|
||||
| claude.ai | Unknown |
|
||||
|
||||
**The SDK throws `CapabilityNotSupported` if the client doesn't advertise elicitation.** There is no graceful degradation built in. You MUST check and have a fallback.
|
||||
|
||||
### The canonical pattern
|
||||
|
||||
```typescript
|
||||
server.registerTool("delete_all", {
|
||||
description: "Delete all items after confirmation",
|
||||
inputSchema: {},
|
||||
}, async ({}, extra) => {
|
||||
const caps = server.getClientCapabilities();
|
||||
if (caps?.elicitation) {
|
||||
const r = await server.elicitInput({
|
||||
mode: "form",
|
||||
message: "Delete all items? This cannot be undone.",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: { confirm: { type: "boolean", title: "Confirm deletion" } },
|
||||
required: ["confirm"],
|
||||
},
|
||||
});
|
||||
if (r.action === "accept" && r.content?.confirm) {
|
||||
await deleteAll();
|
||||
return { content: [{ type: "text", text: "Deleted." }] };
|
||||
}
|
||||
return { content: [{ type: "text", text: "Cancelled." }] };
|
||||
}
|
||||
// Fallback: return text asking Claude to relay the question
|
||||
return { content: [{ type: "text", text: "Confirmation required. Please ask the user: 'Delete all items? This cannot be undone.' Then call this tool again with their answer." }] };
|
||||
});
|
||||
```
|
||||
|
||||
```python
|
||||
# fastmcp
|
||||
from fastmcp import Context
|
||||
from fastmcp.exceptions import CapabilityNotSupported
|
||||
|
||||
@mcp.tool
|
||||
async def delete_all(ctx: Context) -> str:
|
||||
try:
|
||||
result = await ctx.elicit("Delete all items? This cannot be undone.", response_type=bool)
|
||||
if result.action == "accept" and result.data:
|
||||
await do_delete()
|
||||
return "Deleted."
|
||||
return "Cancelled."
|
||||
except CapabilityNotSupported:
|
||||
return "Confirmation required. Ask the user to confirm deletion, then retry."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema constraints
|
||||
|
||||
Elicitation schemas are deliberately limited — keep forms simple:
|
||||
|
||||
- **Flat objects only** — no nesting, no arrays of objects
|
||||
- **Primitives only** — `string`, `number`, `integer`, `boolean`, `enum`
|
||||
- String formats limited to: `email`, `uri`, `date`, `date-time`
|
||||
- Use `title` and `description` on each property — they become form labels
|
||||
|
||||
If your data doesn't fit these constraints, that's the signal to escalate to a widget.
|
||||
|
||||
---
|
||||
|
||||
## Three-state response
|
||||
|
||||
| Action | Meaning | `content` present? |
|
||||
|---|---|---|
|
||||
| `accept` | User submitted the form | ✅ validated against your schema |
|
||||
| `decline` | User explicitly said no | ❌ |
|
||||
| `cancel` | User dismissed (escape, clicked away) | ❌ |
|
||||
|
||||
Treat `decline` and `cancel` differently if it matters — `decline` is intentional, `cancel` might be accidental.
|
||||
|
||||
The TS SDK's `server.elicitInput()` auto-validates `accept` responses against your schema via Ajv. fastmcp's `ctx.elicit()` returns a typed discriminated union (`AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation`).
|
||||
|
||||
---
|
||||
|
||||
## fastmcp response_type shorthand
|
||||
|
||||
```python
|
||||
await ctx.elicit("Pick a color", response_type=["red", "green", "blue"]) # enum
|
||||
await ctx.elicit("Enter email", response_type=str) # string
|
||||
await ctx.elicit("Confirm?", response_type=bool) # boolean
|
||||
|
||||
@dataclass
|
||||
class ContactInfo:
|
||||
name: str
|
||||
email: str
|
||||
await ctx.elicit("Contact details", response_type=ContactInfo) # flat dataclass
|
||||
```
|
||||
|
||||
Accepts: primitives, `list[str]` (becomes enum), dataclass, TypedDict, Pydantic BaseModel. All must be flat.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
**MUST NOT request passwords, API keys, or tokens via elicitation** — spec requirement. Those go through OAuth or `user_config` with `sensitive: true` (MCPB), not runtime forms.
|
||||
|
||||
---
|
||||
|
||||
## When to escalate to widgets
|
||||
|
||||
Elicitation handles: confirm dialogs, enum pickers, short flat forms.
|
||||
|
||||
Reach for `build-mcp-app` widgets when you need:
|
||||
- Nested or complex data structures
|
||||
- Scrollable/searchable lists (100+ items)
|
||||
- Visual preview before choosing (image thumbnails, file tree)
|
||||
- Live-updating progress or streaming content
|
||||
- Custom layouts, charts, maps
|
||||
@@ -20,20 +20,24 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
||||
import express from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "my-service",
|
||||
version: "0.1.0",
|
||||
});
|
||||
const server = new McpServer(
|
||||
{ name: "my-service", version: "0.1.0" },
|
||||
{ instructions: "Prefer search_items before calling get_item directly — IDs aren't guessable." },
|
||||
);
|
||||
|
||||
// Pattern A: one tool per action
|
||||
server.tool(
|
||||
server.registerTool(
|
||||
"search_items",
|
||||
"Search items by keyword. Returns up to `limit` matches ranked by relevance.",
|
||||
{
|
||||
query: z.string().describe("Search keywords"),
|
||||
limit: z.number().int().min(1).max(50).default(10),
|
||||
description: "Search items by keyword. Returns up to `limit` matches ranked by relevance.",
|
||||
inputSchema: {
|
||||
query: z.string().describe("Search keywords"),
|
||||
limit: z.number().int().min(1).max(50).default(10),
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
async ({ query, limit }) => {
|
||||
async ({ query, limit }, extra) => {
|
||||
// extra.signal is an AbortSignal — check it in long loops for cancellation
|
||||
const results = await upstreamApi.search(query, limit);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
||||
@@ -41,10 +45,13 @@ server.tool(
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
server.registerTool(
|
||||
"get_item",
|
||||
"Fetch a single item by its ID.",
|
||||
{ id: z.string() },
|
||||
{
|
||||
description: "Fetch a single item by its ID.",
|
||||
inputSchema: { id: z.string() },
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
async ({ id }) => {
|
||||
const item = await upstreamApi.get(id);
|
||||
return { content: [{ type: "text", text: JSON.stringify(item) }] };
|
||||
@@ -71,7 +78,7 @@ app.listen(process.env.PORT ?? 3000);
|
||||
|
||||
---
|
||||
|
||||
## FastMCP 2.0 (Python)
|
||||
## FastMCP 3.x (Python)
|
||||
|
||||
```bash
|
||||
pip install fastmcp
|
||||
@@ -82,14 +89,17 @@ pip install fastmcp
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP(name="my-service")
|
||||
mcp = FastMCP(
|
||||
name="my-service",
|
||||
instructions="Prefer search_items before calling get_item directly — IDs aren't guessable.",
|
||||
)
|
||||
|
||||
@mcp.tool
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
def search_items(query: str, limit: int = 10) -> list[dict]:
|
||||
"""Search items by keyword. Returns up to `limit` matches ranked by relevance."""
|
||||
return upstream_api.search(query, limit)
|
||||
|
||||
@mcp.tool
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
def get_item(id: str) -> dict:
|
||||
"""Fetch a single item by its ID."""
|
||||
return upstream_api.get(id)
|
||||
@@ -109,22 +119,27 @@ When wrapping 50+ endpoints, don't register them all. Two tools:
|
||||
```typescript
|
||||
const CATALOG = loadActionCatalog(); // { id, description, paramSchema }[]
|
||||
|
||||
server.tool(
|
||||
server.registerTool(
|
||||
"search_actions",
|
||||
"Find available actions matching an intent. Call this first to discover what's possible. Returns action IDs, descriptions, and parameter schemas.",
|
||||
{ intent: z.string().describe("What you want to do, in plain English") },
|
||||
{
|
||||
description: "Find available actions matching an intent. Call this first to discover what's possible. Returns action IDs, descriptions, and parameter schemas.",
|
||||
inputSchema: { intent: z.string().describe("What you want to do, in plain English") },
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
async ({ intent }) => {
|
||||
const matches = rankActions(CATALOG, intent).slice(0, 10);
|
||||
return { content: [{ type: "text", text: JSON.stringify(matches, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
server.registerTool(
|
||||
"execute_action",
|
||||
"Execute an action by ID. Get the ID and params schema from search_actions first.",
|
||||
{
|
||||
action_id: z.string(),
|
||||
params: z.record(z.unknown()),
|
||||
description: "Execute an action by ID. Get the ID and params schema from search_actions first.",
|
||||
inputSchema: {
|
||||
action_id: z.string(),
|
||||
params: z.record(z.unknown()),
|
||||
},
|
||||
},
|
||||
async ({ action_id, params }) => {
|
||||
const action = CATALOG.find(a => a.id === action_id);
|
||||
@@ -146,6 +161,9 @@ server.tool(
|
||||
- [ ] `tools/list` returns your tools with complete schemas
|
||||
- [ ] Errors return structured MCP errors, not HTTP 500s with HTML bodies
|
||||
- [ ] CORS headers set if browser clients will connect
|
||||
- [ ] `Origin` header validated on `/mcp` (spec MUST — DNS rebinding prevention)
|
||||
- [ ] `MCP-Protocol-Version` header honored (return 400 for unsupported versions)
|
||||
- [ ] `instructions` field set if tool-use needs hints
|
||||
- [ ] Health check endpoint separate from `/mcp` (hosts poll it)
|
||||
- [ ] Secrets from env vars, never hardcoded
|
||||
- [ ] If OAuth: DCR endpoint implemented — see `auth.md`
|
||||
- [ ] If OAuth: CIMD or DCR endpoint implemented — see `auth.md`
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Resources & Prompts — the other two primitives
|
||||
|
||||
MCP defines three server-side primitives. Tools are model-controlled (Claude decides when to call them). The other two are different:
|
||||
|
||||
- **Resources** are application-controlled — the host decides what to pull into context
|
||||
- **Prompts** are user-controlled — surfaced as slash commands or menu items
|
||||
|
||||
Most servers only need tools. Reach for these when the shape of your integration doesn't fit "Claude calls a function."
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
A resource is data identified by a URI. Unlike a tool, it's not *called* — it's *read*. The host browses available resources and decides which to load into context.
|
||||
|
||||
**When a resource beats a tool:**
|
||||
- Large reference data (docs, schemas, configs) that Claude should be able to browse
|
||||
- Content that changes independently of conversation (log files, live data)
|
||||
- Anything where "Claude decides to fetch" is the wrong mental model
|
||||
|
||||
**When a tool is better:**
|
||||
- The operation has side effects
|
||||
- The result depends on parameters Claude chooses
|
||||
- You want Claude (not the host UI) to decide when to pull it in
|
||||
|
||||
### Static resources
|
||||
|
||||
```typescript
|
||||
// TypeScript SDK
|
||||
server.registerResource(
|
||||
"config",
|
||||
"config://app/settings",
|
||||
{ name: "App Settings", description: "Current configuration", mimeType: "application/json" },
|
||||
async (uri) => ({
|
||||
contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(config) }],
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
```python
|
||||
# fastmcp
|
||||
@mcp.resource("config://app/settings")
|
||||
def get_settings() -> str:
|
||||
"""Current application configuration."""
|
||||
return json.dumps(config)
|
||||
```
|
||||
|
||||
### Dynamic resources (URI templates)
|
||||
|
||||
RFC 6570 templates let one registration serve many URIs:
|
||||
|
||||
```typescript
|
||||
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
|
||||
server.registerResource(
|
||||
"file",
|
||||
new ResourceTemplate("file:///{path}", { list: undefined }),
|
||||
{ name: "File", description: "Read a file from the workspace" },
|
||||
async (uri, { path }) => ({
|
||||
contents: [{ uri: uri.href, text: await fs.readFile(path, "utf8") }],
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
```python
|
||||
@mcp.resource("file:///{path}")
|
||||
def read_file(path: str) -> str:
|
||||
return Path(path).read_text()
|
||||
```
|
||||
|
||||
### Subscriptions
|
||||
|
||||
Resources can notify the client when they change. Declare `subscribe: true` in capabilities, then emit `notifications/resources/updated`. The host re-reads. Useful for log tails, live dashboards, watched files.
|
||||
|
||||
---
|
||||
|
||||
## Prompts
|
||||
|
||||
A prompt is a parameterized message template. The host surfaces it as a slash command or menu item. The user picks it, fills in arguments, and the resulting messages land in the conversation.
|
||||
|
||||
**When to use:** canned workflows users run repeatedly — `/summarize-thread`, `/draft-reply`, `/explain-error`. Near-zero code, high UX leverage.
|
||||
|
||||
```typescript
|
||||
server.registerPrompt(
|
||||
"summarize",
|
||||
{
|
||||
title: "Summarize document",
|
||||
description: "Generate a concise summary of the given text",
|
||||
argsSchema: { text: z.string(), max_words: z.string().optional() },
|
||||
},
|
||||
({ text, max_words }) => ({
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: { type: "text", text: `Summarize in ${max_words ?? "100"} words:\n\n${text}` },
|
||||
}],
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
```python
|
||||
@mcp.prompt
|
||||
def summarize(text: str, max_words: str = "100") -> str:
|
||||
"""Generate a concise summary of the given text."""
|
||||
return f"Summarize in {max_words} words:\n\n{text}"
|
||||
```
|
||||
|
||||
**Constraints:**
|
||||
- Arguments are **string-only** (no numbers, booleans, objects) — convert inside the handler
|
||||
- Returns a `messages[]` array — can include embedded resources/images, not just text
|
||||
- No side effects — the handler just builds a message, it doesn't *do* anything
|
||||
|
||||
---
|
||||
|
||||
## Quick decision table
|
||||
|
||||
| You want to... | Use |
|
||||
|---|---|
|
||||
| Let Claude fetch something on demand, with parameters | **Tool** |
|
||||
| Expose browsable context (files, docs, schemas) | **Resource** |
|
||||
| Expose a dynamic family of things (`db://{table}`) | **Resource template** |
|
||||
| Give users a one-click workflow | **Prompt** |
|
||||
| Ask the user something mid-tool | **Elicitation** (see `elicitation.md`) |
|
||||
@@ -0,0 +1,164 @@
|
||||
# Server capabilities — the rest of the spec
|
||||
|
||||
Features beyond the three core primitives. Most are optional, a few are near-free wins.
|
||||
|
||||
---
|
||||
|
||||
## `instructions` — system prompt injection
|
||||
|
||||
One line of config, lands directly in Claude's system prompt. Use it for tool-use hints that don't fit in individual tool descriptions.
|
||||
|
||||
```typescript
|
||||
const server = new McpServer(
|
||||
{ name: "my-server", version: "1.0.0" },
|
||||
{ instructions: "Always call search_items before get_item — IDs aren't guessable." },
|
||||
);
|
||||
```
|
||||
|
||||
```python
|
||||
mcp = FastMCP("my-server", instructions="Always call search_items before get_item — IDs aren't guessable.")
|
||||
```
|
||||
|
||||
This is the highest-leverage one-liner in the spec. If Claude keeps misusing your tools, put the fix here.
|
||||
|
||||
---
|
||||
|
||||
## Sampling — delegate LLM calls to the host
|
||||
|
||||
If your tool logic needs LLM inference (summarize, classify, generate), don't ship your own model client. Ask the host to do it.
|
||||
|
||||
```typescript
|
||||
// Inside a tool handler
|
||||
const result = await extra.sendRequest({
|
||||
method: "sampling/createMessage",
|
||||
params: {
|
||||
messages: [{ role: "user", content: { type: "text", text: `Summarize: ${doc}` } }],
|
||||
maxTokens: 500,
|
||||
},
|
||||
}, CreateMessageResultSchema);
|
||||
```
|
||||
|
||||
```python
|
||||
# fastmcp
|
||||
response = await ctx.sample("Summarize this document", context=doc)
|
||||
```
|
||||
|
||||
**Requires client support** — check `clientCapabilities.sampling` first. Model preference hints are substring-matched (`"claude-3-5"` matches any Claude 3.5 variant).
|
||||
|
||||
---
|
||||
|
||||
## Roots — query workspace boundaries
|
||||
|
||||
Instead of hardcoding a root directory, ask the host which directories the user approved.
|
||||
|
||||
```typescript
|
||||
const caps = server.getClientCapabilities();
|
||||
if (caps?.roots) {
|
||||
const { roots } = await server.server.listRoots();
|
||||
// roots: [{ uri: "file:///home/user/project", name: "My Project" }]
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
roots = await ctx.list_roots()
|
||||
```
|
||||
|
||||
Particularly relevant for MCPB local servers — see `build-mcpb/references/local-security.md`.
|
||||
|
||||
---
|
||||
|
||||
## Logging — structured, level-aware
|
||||
|
||||
Better than stderr for remote servers. Client can filter by level.
|
||||
|
||||
```typescript
|
||||
// In a tool handler
|
||||
await extra.sendNotification({
|
||||
method: "notifications/message",
|
||||
params: { level: "info", logger: "my-tool", data: { msg: "Processing", count: 42 } },
|
||||
});
|
||||
```
|
||||
|
||||
```python
|
||||
await ctx.info("Processing", count=42) # also: ctx.debug, ctx.warning, ctx.error
|
||||
```
|
||||
|
||||
Levels follow syslog: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`. Client sets minimum via `logging/setLevel`.
|
||||
|
||||
---
|
||||
|
||||
## Progress — for long-running tools
|
||||
|
||||
Client sends a `progressToken` in request `_meta`. Server emits progress notifications against it.
|
||||
|
||||
```typescript
|
||||
async (args, extra) => {
|
||||
const token = extra._meta?.progressToken;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (token !== undefined) {
|
||||
await extra.sendNotification({
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: token, progress: i, total: 100, message: `Step ${i}` },
|
||||
});
|
||||
}
|
||||
await doStep(i);
|
||||
}
|
||||
return { content: [{ type: "text", text: "Done" }] };
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
async def long_task(ctx: Context) -> str:
|
||||
for i in range(100):
|
||||
await ctx.report_progress(progress=i, total=100, message=f"Step {i}")
|
||||
await do_step(i)
|
||||
return "Done"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cancellation — honor the abort signal
|
||||
|
||||
Long tools should check the SDK-provided `AbortSignal`:
|
||||
|
||||
```typescript
|
||||
async (args, extra) => {
|
||||
for (const item of items) {
|
||||
if (extra.signal.aborted) throw new Error("Cancelled");
|
||||
await process(item);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
fastmcp handles this via asyncio cancellation — no explicit check needed if your handler is properly async.
|
||||
|
||||
---
|
||||
|
||||
## Completion — autocomplete for prompt args
|
||||
|
||||
If you've registered prompts or resource templates with arguments, you can offer autocomplete:
|
||||
|
||||
```typescript
|
||||
server.registerPrompt("query", {
|
||||
argsSchema: {
|
||||
table: completable(z.string(), async (partial) => tables.filter(t => t.startsWith(partial))),
|
||||
},
|
||||
}, ...);
|
||||
```
|
||||
|
||||
Low priority unless your prompts have many valid values.
|
||||
|
||||
---
|
||||
|
||||
## Which capabilities need client support?
|
||||
|
||||
| Feature | Server declares | Client must support | Fallback if not |
|
||||
|---|---|---|---|
|
||||
| `instructions` | implicit | — | — (always works) |
|
||||
| Logging | `logging: {}` | — | stderr |
|
||||
| Progress | — | sends `progressToken` | silently skip |
|
||||
| Sampling | — | `sampling: {}` | bring your own LLM |
|
||||
| Elicitation | — | `elicitation: {}` | return text, ask Claude to relay |
|
||||
| Roots | — | `roots: {}` | config env var |
|
||||
|
||||
Check client caps via `server.getClientCapabilities()` (TS) or `ctx.session.client_params.capabilities` (fastmcp) before using the bottom three.
|
||||
@@ -110,3 +110,70 @@ if (!item) {
|
||||
```
|
||||
|
||||
The hint ("use search_items…") turns a dead end into a next step.
|
||||
|
||||
---
|
||||
|
||||
## Tool annotations
|
||||
|
||||
Hints the host uses for UX — red confirm button for destructive, auto-approve for readonly. All default to unset (host assumes worst case).
|
||||
|
||||
| Annotation | Meaning | Host behavior |
|
||||
|---|---|---|
|
||||
| `readOnlyHint: true` | No side effects | May auto-approve |
|
||||
| `destructiveHint: true` | Deletes/overwrites | Confirmation dialog |
|
||||
| `idempotentHint: true` | Safe to retry | May retry on transient error |
|
||||
| `openWorldHint: true` | Talks to external world (web, APIs) | May show network indicator |
|
||||
|
||||
```typescript
|
||||
server.registerTool("delete_file", {
|
||||
description: "Delete a file",
|
||||
inputSchema: { path: z.string() },
|
||||
annotations: { destructiveHint: true, idempotentHint: false },
|
||||
}, handler);
|
||||
```
|
||||
|
||||
```python
|
||||
@mcp.tool(annotations={"destructiveHint": True, "idempotentHint": False})
|
||||
def delete_file(path: str) -> str:
|
||||
...
|
||||
```
|
||||
|
||||
Pair with the read/write split advice in `build-mcpb/references/local-security.md` — mark every read tool `readOnlyHint: true`.
|
||||
|
||||
---
|
||||
|
||||
## Structured output
|
||||
|
||||
`JSON.stringify(result)` in a text block works, but the spec has first-class typed output: `outputSchema` + `structuredContent`. Clients can validate.
|
||||
|
||||
```typescript
|
||||
server.registerTool("get_weather", {
|
||||
description: "Get current weather",
|
||||
inputSchema: { city: z.string() },
|
||||
outputSchema: { temp: z.number(), conditions: z.string() },
|
||||
}, async ({ city }) => {
|
||||
const data = await fetchWeather(city);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(data) }], // backward compat
|
||||
structuredContent: data, // typed output
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
Always include the text fallback — not all hosts read `structuredContent` yet.
|
||||
|
||||
---
|
||||
|
||||
## Content types beyond text
|
||||
|
||||
Tools can return more than strings:
|
||||
|
||||
| Type | Shape | Use for |
|
||||
|---|---|---|
|
||||
| `text` | `{ type: "text", text: string }` | Default |
|
||||
| `image` | `{ type: "image", data: base64, mimeType }` | Screenshots, charts, diagrams |
|
||||
| `audio` | `{ type: "audio", data: base64, mimeType }` | TTS output, recordings |
|
||||
| `resource_link` | `{ type: "resource_link", uri, name?, description? }` | Pointer — client fetches later |
|
||||
| `resource` (embedded) | `{ type: "resource", resource: { uri, text\|blob, mimeType } }` | Inline the full content |
|
||||
|
||||
**`resource_link` vs embedded:** link for large payloads or when the client might not need it (let them decide). Embed when it's small and always needed.
|
||||
|
||||
@@ -16,15 +16,14 @@ MCPB is a local MCP server **packaged with its runtime**. The user installs one
|
||||
|
||||
```
|
||||
my-server.mcpb (zip archive)
|
||||
├── manifest.json ← identity, entry point, permissions, config schema
|
||||
├── manifest.json ← identity, entry point, config schema, compatibility
|
||||
├── server/ ← your MCP server code
|
||||
│ ├── index.js
|
||||
│ └── node_modules/ ← bundled dependencies (or vendored)
|
||||
├── runtime/ ← optional: pinned Node/Python if not using host's
|
||||
└── icon.png
|
||||
```
|
||||
|
||||
The host reads `manifest.json`, launches the entry point as a **stdio** MCP server, and pipes messages. From your code's perspective it's identical to a local stdio server — the only difference is packaging.
|
||||
The host reads `manifest.json`, launches `server.mcp_config.command` as a **stdio** MCP server, and pipes messages. From your code's perspective it's identical to a local stdio server — the only difference is packaging.
|
||||
|
||||
---
|
||||
|
||||
@@ -32,32 +31,44 @@ The host reads `manifest.json`, launches the entry point as a **stdio** MCP serv
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/anthropics/mcpb/main/schemas/mcpb-manifest-v0.4.schema.json",
|
||||
"manifest_version": "0.4",
|
||||
"name": "local-files",
|
||||
"version": "0.1.0",
|
||||
"description": "Read, search, and watch files on the local filesystem.",
|
||||
"entry": {
|
||||
"author": { "name": "Your Name" },
|
||||
"server": {
|
||||
"type": "node",
|
||||
"main": "server/index.js"
|
||||
},
|
||||
"permissions": {
|
||||
"filesystem": { "read": true, "write": false },
|
||||
"network": false
|
||||
},
|
||||
"config": {
|
||||
"rootDir": {
|
||||
"type": "string",
|
||||
"description": "Directory to expose. Defaults to ~/Documents.",
|
||||
"default": "~/Documents"
|
||||
"entry_point": "server/index.js",
|
||||
"mcp_config": {
|
||||
"command": "node",
|
||||
"args": ["${__dirname}/server/index.js"],
|
||||
"env": {
|
||||
"ROOT_DIR": "${user_config.rootDir}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"user_config": {
|
||||
"rootDir": {
|
||||
"type": "directory",
|
||||
"title": "Root directory",
|
||||
"description": "Directory to expose. Defaults to ~/Documents.",
|
||||
"default": "${HOME}/Documents",
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"compatibility": {
|
||||
"claude_desktop": ">=1.0.0",
|
||||
"platforms": ["darwin", "win32", "linux"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`entry.type`** — `node`, `python`, or `binary`. Determines which bundled/host runtime launches `main`.
|
||||
**`server.type`** — `node`, `python`, or `binary`. Informational; the actual launch comes from `mcp_config`.
|
||||
|
||||
**`permissions`** — declared upfront and shown to the user at install. Request the minimum. Broad permissions (`filesystem.write: true`, `network: true`) trigger scarier consent UI and more scrutiny.
|
||||
**`server.mcp_config`** — the literal command/args/env to spawn. Use `${__dirname}` for bundle-relative paths and `${user_config.<key>}` to substitute install-time config. **There's no auto-prefix** — the env var names your server reads are exactly what you put in `env`.
|
||||
|
||||
**`config`** — user-settable values surfaced in the host's settings UI. Your server reads them from env vars (`MCPB_CONFIG_<KEY>`).
|
||||
**`user_config`** — install-time settings surfaced in the host's UI. `type: "directory"` renders a native folder picker. `sensitive: true` stores in OS keychain. See `references/manifest-schema.md` for all fields.
|
||||
|
||||
---
|
||||
|
||||
@@ -73,15 +84,18 @@ import { readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const ROOT = (process.env.MCPB_CONFIG_ROOTDIR ?? "~/Documents")
|
||||
.replace(/^~/, homedir());
|
||||
// ROOT_DIR comes from what you put in manifest's server.mcp_config.env — no auto-prefix
|
||||
const ROOT = (process.env.ROOT_DIR ?? join(homedir(), "Documents"));
|
||||
|
||||
const server = new McpServer({ name: "local-files", version: "0.1.0" });
|
||||
|
||||
server.tool(
|
||||
server.registerTool(
|
||||
"list_files",
|
||||
"List files in a directory under the configured root.",
|
||||
{ path: z.string().default(".") },
|
||||
{
|
||||
description: "List files in a directory under the configured root.",
|
||||
inputSchema: { path: z.string().default(".") },
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
async ({ path }) => {
|
||||
const entries = await readdir(join(ROOT, path), { withFileTypes: true });
|
||||
const list = entries.map(e => ({ name: e.name, dir: e.isDirectory() }));
|
||||
@@ -89,10 +103,13 @@ server.tool(
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
server.registerTool(
|
||||
"read_file",
|
||||
"Read a file's contents. Path is relative to the configured root.",
|
||||
{ path: z.string() },
|
||||
{
|
||||
description: "Read a file's contents. Path is relative to the configured root.",
|
||||
inputSchema: { path: z.string() },
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
async ({ path }) => {
|
||||
const text = await readFile(join(ROOT, path), "utf8");
|
||||
return { content: [{ type: "text", text }] };
|
||||
@@ -103,7 +120,9 @@ const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
```
|
||||
|
||||
**Sandboxing is your job.** The manifest permissions gate what the *host* allows the process to do, but don't rely on that alone — validate paths, refuse to escape `ROOT`, etc. See `references/local-security.md`.
|
||||
**Sandboxing is entirely your job.** There is no manifest-level sandbox — the process runs with full user privileges. Validate paths, refuse to escape `ROOT`, allowlist spawns. See `references/local-security.md`.
|
||||
|
||||
Before hardcoding `ROOT` from a config env var, check if the host supports `roots/list` — the spec-native way to get user-approved directories. See `references/local-security.md` for the pattern.
|
||||
|
||||
---
|
||||
|
||||
@@ -115,35 +134,29 @@ await server.connect(transport);
|
||||
npm install
|
||||
npx esbuild src/index.ts --bundle --platform=node --outfile=server/index.js
|
||||
# or: copy node_modules wholesale if native deps resist bundling
|
||||
npx @modelcontextprotocol/mcpb pack . -o my-server.mcpb
|
||||
npx @anthropic-ai/mcpb pack
|
||||
```
|
||||
|
||||
`mcpb pack` zips the directory, validates `manifest.json`, and optionally pulls a pinned Node runtime into `runtime/`.
|
||||
`mcpb pack` zips the directory and validates `manifest.json` against the schema.
|
||||
|
||||
### Python
|
||||
|
||||
```bash
|
||||
pip install -t server/vendor -r requirements.txt
|
||||
npx @modelcontextprotocol/mcpb pack . -o my-server.mcpb --runtime python3.12
|
||||
npx @anthropic-ai/mcpb pack
|
||||
```
|
||||
|
||||
Vendor dependencies into a subdirectory and prepend it to `sys.path` in your entry script. Native extensions (numpy, etc.) must be built for each target platform — `mcpb pack --multiarch` cross-builds, but it's slow; avoid native deps if you can.
|
||||
Vendor dependencies into a subdirectory and prepend it to `sys.path` in your entry script. Native extensions (numpy, etc.) must be built for each target platform — avoid native deps if you can.
|
||||
|
||||
---
|
||||
|
||||
## Permissions: request the minimum
|
||||
## MCPB has no sandbox — security is on you
|
||||
|
||||
The install prompt shows what you ask for. Every extra permission is friction.
|
||||
Unlike mobile app stores, MCPB does NOT enforce permissions. The manifest has no `permissions` block — the server runs with full user privileges. `references/local-security.md` is mandatory reading, not optional. Every path must be validated, every spawn must be allowlisted, because nothing stops you at the platform level.
|
||||
|
||||
| Need | Request |
|
||||
|---|---|
|
||||
| Read files in one directory | `filesystem.read: true` + enforce root in code |
|
||||
| Write files | `filesystem.write: true` — justify in description |
|
||||
| Call a local HTTP service | `network: { "allow": ["localhost:*"] }` |
|
||||
| Call the internet | `network: true` — but ask yourself why this isn't a remote server |
|
||||
| Spawn processes | `process.spawn: true` — highest scrutiny |
|
||||
If you came here expecting filesystem/network scoping from the manifest: it doesn't exist. Build it yourself in tool handlers.
|
||||
|
||||
If you find yourself requesting `network: true` to hit a cloud API, stop — that's a remote server wearing an MCPB costume. The user gains nothing from running it locally.
|
||||
If your server's only job is hitting a cloud API, stop — that's a remote server wearing an MCPB costume. The user gains nothing from running it locally, and you're taking on local-security burden for no reason.
|
||||
|
||||
---
|
||||
|
||||
@@ -158,15 +171,20 @@ Widget authoring is covered in the **`build-mcp-app`** skill; it works the same
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Interactive manifest creation (first time)
|
||||
npx @anthropic-ai/mcpb init
|
||||
|
||||
# Run the server directly over stdio, poke it with the inspector
|
||||
npx @modelcontextprotocol/inspector node server/index.js
|
||||
|
||||
# Pack and validate
|
||||
npx @modelcontextprotocol/mcpb pack . -o test.mcpb
|
||||
npx @modelcontextprotocol/mcpb validate test.mcpb
|
||||
# Validate manifest against schema, then pack
|
||||
npx @anthropic-ai/mcpb validate
|
||||
npx @anthropic-ai/mcpb pack
|
||||
|
||||
# Install into Claude desktop for end-to-end
|
||||
npx @modelcontextprotocol/mcpb install test.mcpb
|
||||
# Sign for distribution
|
||||
npx @anthropic-ai/mcpb sign dist/local-files.mcpb
|
||||
|
||||
# Install: drag the .mcpb file onto Claude Desktop
|
||||
```
|
||||
|
||||
Test on a machine **without** your dev toolchain before shipping. "Works on my machine" failures in MCPB almost always trace to a dependency that wasn't actually bundled.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Local MCP Security
|
||||
|
||||
An MCPB server runs as the user, with whatever permissions the manifest was granted. Claude drives it. That combination means: **tool inputs are untrusted**, even though they come from an AI the user trusts. A prompt-injected web page can make Claude call your `delete_file` tool with a path you didn't intend.
|
||||
**MCPB provides no sandbox.** There's no `permissions` block in the manifest, no filesystem scoping, no network allowlist enforced by the platform. The server process runs with the user's full privileges — it can read any file the user can, spawn any process, hit any network endpoint.
|
||||
|
||||
Defense in depth. Manifest permissions are the outer wall; validation in your tool handlers is the inner wall.
|
||||
Claude drives it. That combination means: **tool inputs are untrusted**, even though they come from an AI the user trusts. A prompt-injected web page can make Claude call your `delete_file` tool with a path you didn't intend.
|
||||
|
||||
Your tool handlers are the only defense. Everything below is about building that defense yourself.
|
||||
|
||||
---
|
||||
|
||||
@@ -39,6 +41,41 @@ def safe_join(root: Path, user_path: str) -> Path:
|
||||
|
||||
---
|
||||
|
||||
## Roots — ask the host, don't hardcode
|
||||
|
||||
Before hardcoding `ROOT` from a config env var, check if the host supports `roots/list`. This is the spec-native way to get user-approved workspace boundaries.
|
||||
|
||||
```typescript
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
|
||||
const server = new McpServer({ name: "...", version: "..." });
|
||||
|
||||
let allowedRoots: string[] = [];
|
||||
server.server.oninitialized = async () => {
|
||||
const caps = server.getClientCapabilities();
|
||||
if (caps?.roots) {
|
||||
const { roots } = await server.server.listRoots();
|
||||
allowedRoots = roots.map(r => new URL(r.uri).pathname);
|
||||
} else {
|
||||
allowedRoots = [process.env.ROOT_DIR ?? process.cwd()];
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
```python
|
||||
# fastmcp — inside a tool handler
|
||||
async def my_tool(ctx: Context) -> str:
|
||||
try:
|
||||
roots = await ctx.list_roots()
|
||||
allowed = [urlparse(r.uri).path for r in roots]
|
||||
except Exception:
|
||||
allowed = [os.environ.get("ROOT_DIR", os.getcwd())]
|
||||
```
|
||||
|
||||
If roots are available, use them. If not, fall back to config. Either way, validate every path against the allowed set.
|
||||
|
||||
---
|
||||
|
||||
## Command injection
|
||||
|
||||
If you spawn processes, **never pass user input through a shell**.
|
||||
@@ -62,11 +99,13 @@ Split read and write into separate tools. Most workflows only need read. A tool
|
||||
```
|
||||
list_files ← safe to call freely
|
||||
read_file ← safe to call freely
|
||||
write_file ← separate tool, separate permission, separate scrutiny
|
||||
write_file ← separate tool, separate scrutiny
|
||||
delete_file ← consider not shipping this at all
|
||||
```
|
||||
|
||||
If you ship write/delete, consider requiring a confirmation widget (see `build-mcp-app`) so the user explicitly approves each destructive call.
|
||||
Pair this with tool annotations — `readOnlyHint: true` on every read tool, `destructiveHint: true` on delete/overwrite tools. Hosts surface these in permission UI (auto-approve reads, confirm-dialog destructive). See `../build-mcp-server/references/tool-design.md`.
|
||||
|
||||
If you ship write/delete, consider requiring explicit confirmation via elicitation (see `../build-mcp-server/references/elicitation.md`) or a confirmation widget (see `build-mcp-app`) so the user approves each destructive call.
|
||||
|
||||
---
|
||||
|
||||
@@ -94,7 +133,7 @@ Same for directory listings (cap entry count), search results (cap matches), and
|
||||
|
||||
## Secrets
|
||||
|
||||
- **Config secrets** (`secret: true` in manifest): host stores in OS keychain, delivers via env var. Don't log them. Don't include them in tool results.
|
||||
- **Config secrets** (`sensitive: true` in manifest `user_config`): host stores in OS keychain, delivers via env var. Don't log them. Don't include them in tool results.
|
||||
- **Never store secrets in plaintext files.** If the host's keychain integration isn't enough, use `keytar` (Node) / `keyring` (Python) yourself.
|
||||
- **Tool results flow into the chat transcript.** Anything you return, the user (and any log export) can see. Redact before returning.
|
||||
|
||||
@@ -104,8 +143,7 @@ Same for directory listings (cap entry count), search results (cap matches), and
|
||||
|
||||
- [ ] Every path parameter goes through containment check
|
||||
- [ ] No `exec()` / `shell=True` — `execFile` / array-argv only
|
||||
- [ ] Write/delete split from read tools
|
||||
- [ ] Write/delete split from read tools; `readOnlyHint`/`destructiveHint` annotations set
|
||||
- [ ] Size caps on file reads, listing lengths, search results
|
||||
- [ ] Manifest permissions match actual code behavior (no over-requesting)
|
||||
- [ ] Secrets never logged or returned in tool results
|
||||
- [ ] Tested with adversarial inputs: `../../etc/passwd`, `; rm -rf ~`, 10GB file
|
||||
|
||||
@@ -1,119 +1,127 @@
|
||||
# MCPB Manifest Schema
|
||||
# MCPB Manifest Schema (v0.4)
|
||||
|
||||
Every `.mcpb` bundle has a `manifest.json` at its root. The host validates it before install.
|
||||
Validated against `github.com/anthropics/mcpb/schemas/mcpb-manifest-v0.4.schema.json`. The schema uses `additionalProperties: false` — unknown keys are rejected. Add `"$schema"` to your manifest for editor validation.
|
||||
|
||||
---
|
||||
|
||||
## Top-level fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | ✅ | Unique identifier. Lowercase, hyphens only. Shown in settings. |
|
||||
| `version` | string | ✅ | Semver. Host compares for update prompts. |
|
||||
| `description` | string | ✅ | One line. Shown in the install prompt. |
|
||||
| `entry` | object | ✅ | How to launch the server — see below. |
|
||||
| `permissions` | object | ✅ | What the server needs — see below. |
|
||||
| `config` | object | — | User-settable values surfaced in settings UI. |
|
||||
| `icon` | string | — | Path to PNG inside the bundle. 256×256 recommended. |
|
||||
| `homepage` | string | — | URL shown in settings. |
|
||||
| `minHostVersion` | string | — | Refuse install on older hosts. |
|
||||
|
||||
---
|
||||
|
||||
## `entry`
|
||||
|
||||
```json
|
||||
{ "type": "node", "main": "server/index.js" }
|
||||
```
|
||||
|
||||
| `type` | `main` points at | Runtime resolution |
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `node` | `.js` or `.mjs` file | `runtime/node` if present, else host-bundled Node |
|
||||
| `python` | `.py` file | `runtime/python` if present, else host-bundled Python |
|
||||
| `binary` | executable | Run directly. Must be built per-platform. |
|
||||
|
||||
**`args`** (optional array) — extra argv passed to the entry. Rarely needed.
|
||||
|
||||
**`env`** (optional object) — static env vars set at launch. For user-configurable values use `config` instead.
|
||||
| `manifest_version` | ✅ | Schema version. Use `"0.4"`. |
|
||||
| `name` | ✅ | Package identifier (lowercase, hyphens). Must be unique. |
|
||||
| `version` | ✅ | Semver version of YOUR package. |
|
||||
| `description` | ✅ | One-line summary. Shown in marketplace. |
|
||||
| `author` | ✅ | `{name, email?, url?}` |
|
||||
| `server` | ✅ | Entry point and launch config. See below. |
|
||||
| `display_name` | | Human-friendly name. Falls back to `name`. |
|
||||
| `long_description` | | Markdown. Shown on detail page. |
|
||||
| `icon` / `icons` | | Path(s) to icon file(s) in the bundle. |
|
||||
| `homepage` / `repository` / `documentation` / `support` | | URLs. |
|
||||
| `license` | | SPDX identifier. |
|
||||
| `keywords` | | String array for search. |
|
||||
| `user_config` | | Install-time config fields. See below. |
|
||||
| `compatibility` | | Host/platform/runtime requirements. See below. |
|
||||
| `tools` / `prompts` | | Optional declarative list for marketplace display. Not enforced at runtime. |
|
||||
| `tools_generated` / `prompts_generated` | | `true` if tools/prompts are dynamic (can't list statically). |
|
||||
| `screenshots` | | Array of image paths. |
|
||||
| `localization` | | i18n bundles. |
|
||||
| `privacy_policies` | | URLs. |
|
||||
|
||||
---
|
||||
|
||||
## `permissions`
|
||||
## `server` — launch configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"filesystem": { "read": true, "write": false },
|
||||
"network": { "allow": ["localhost:*", "127.0.0.1:*"] },
|
||||
"process": { "spawn": false }
|
||||
}
|
||||
```
|
||||
|
||||
### `filesystem`
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `false` or omitted | No filesystem access beyond the bundle itself |
|
||||
| `{ "read": true }` | Read anywhere the OS user can |
|
||||
| `{ "read": true, "write": true }` | Read and write |
|
||||
|
||||
There's no path scoping at the manifest level — scope in your code. The manifest permission is a coarse consent gate, not a sandbox.
|
||||
|
||||
### `network`
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `false` or omitted | No network (most local-first servers) |
|
||||
| `{ "allow": ["host:port", ...] }` | Allowlisted destinations. `*` wildcards ports. |
|
||||
| `true` | Unrestricted. Heavy scrutiny — explain why in `description`. |
|
||||
|
||||
### `process`
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `false` or omitted | Can't spawn child processes |
|
||||
| `{ "spawn": true }` | Can spawn. Needed for wrapping CLIs. |
|
||||
| `{ "spawn": true, "allow": ["git", "ffmpeg"] }` | Spawn only allowlisted binaries |
|
||||
|
||||
---
|
||||
|
||||
## `config`
|
||||
|
||||
User-editable settings, surfaced in the host UI. Each key becomes an env var: `MCPB_CONFIG_<UPPERCASE_KEY>`.
|
||||
|
||||
```json
|
||||
{
|
||||
"config": {
|
||||
"rootDir": {
|
||||
"type": "string",
|
||||
"description": "Directory to expose",
|
||||
"default": "~/Documents"
|
||||
},
|
||||
"maxFileSize": {
|
||||
"type": "number",
|
||||
"description": "Skip files larger than this (MB)",
|
||||
"default": 10,
|
||||
"min": 1,
|
||||
"max": 500
|
||||
},
|
||||
"includeHidden": {
|
||||
"type": "boolean",
|
||||
"description": "Include dotfiles in listings",
|
||||
"default": false
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"description": "Optional API key for the sync feature",
|
||||
"secret": true
|
||||
"server": {
|
||||
"type": "node",
|
||||
"entry_point": "server/index.js",
|
||||
"mcp_config": {
|
||||
"command": "node",
|
||||
"args": ["${__dirname}/server/index.js"],
|
||||
"env": {
|
||||
"API_KEY": "${user_config.apiKey}",
|
||||
"ROOT_DIR": "${user_config.rootDir}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`type`** — `string`, `number`, `boolean`. Enums: use `string` with `"enum": ["a", "b", "c"]`.
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `type` | `"node"`, `"python"`, or `"binary"` |
|
||||
| `entry_point` | Relative path to main file. Informational. |
|
||||
| `mcp_config.command` | Executable to launch. |
|
||||
| `mcp_config.args` | Argv array. Use `${__dirname}` for bundle-relative paths. |
|
||||
| `mcp_config.env` | Environment variables. Use `${user_config.KEY}` to substitute user config. |
|
||||
|
||||
**`secret: true`** — host masks the value in UI and stores it in the OS keychain instead of a plain config file.
|
||||
**Substitution variables** (in `args` and `env` only):
|
||||
- `${__dirname}` — absolute path to the unpacked bundle directory
|
||||
- `${user_config.<key>}` — value the user entered at install time
|
||||
- `${HOME}` — user's home directory
|
||||
|
||||
**`required: true`** — host blocks server launch until the user sets it. Use sparingly — a server that won't start until configured is a bad first-run experience.
|
||||
**There are no auto-prefixed env vars.** The env var names your server reads are exactly what you declare in `mcp_config.env`. If you write `"ROOT_DIR": "${user_config.rootDir}"`, your server reads `process.env.ROOT_DIR`.
|
||||
|
||||
---
|
||||
|
||||
## `user_config` — install-time settings
|
||||
|
||||
```json
|
||||
"user_config": {
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"title": "API Key",
|
||||
"description": "Your service API key. Stored encrypted.",
|
||||
"sensitive": true,
|
||||
"required": true
|
||||
},
|
||||
"rootDir": {
|
||||
"type": "directory",
|
||||
"title": "Root directory",
|
||||
"description": "Directory to expose to the server.",
|
||||
"default": "${HOME}/Documents"
|
||||
},
|
||||
"maxResults": {
|
||||
"type": "number",
|
||||
"title": "Max results",
|
||||
"description": "Maximum items returned per query.",
|
||||
"default": 50,
|
||||
"min": 1,
|
||||
"max": 500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `type` | ✅ | `"string"`, `"number"`, `"boolean"`, `"directory"`, `"file"` |
|
||||
| `title` | ✅ | Form label. |
|
||||
| `description` | ✅ | Help text under the input. |
|
||||
| `default` | | Pre-filled value. Supports `${HOME}`. |
|
||||
| `required` | | If `true`, install blocks until filled. |
|
||||
| `sensitive` | | If `true`, stored in OS keychain + masked in UI. **NOT `secret`** — that field doesn't exist. |
|
||||
| `multiple` | | If `true`, user can enter multiple values (array). |
|
||||
| `min` / `max` | | Numeric bounds (for `type: "number"`). |
|
||||
|
||||
`directory` and `file` types render native OS pickers — prefer these over free-text paths for UX and validation.
|
||||
|
||||
---
|
||||
|
||||
## `compatibility` — gate installs
|
||||
|
||||
```json
|
||||
"compatibility": {
|
||||
"claude_desktop": ">=1.0.0",
|
||||
"platforms": ["darwin", "win32", "linux"],
|
||||
"runtimes": { "node": ">=20" }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `claude_desktop` | Semver range. Install blocked if host is older. |
|
||||
| `platforms` | OS allowlist. Subset of `["darwin", "win32", "linux"]`. |
|
||||
| `runtimes` | Required runtime versions, e.g. `{"node": ">=20"}` or `{"python": ">=3.11"}`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -121,12 +129,28 @@ User-editable settings, surfaced in the host UI. Each key becomes an env var: `M
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/anthropics/mcpb/main/schemas/mcpb-manifest-v0.4.schema.json",
|
||||
"manifest_version": "0.4",
|
||||
"name": "hello",
|
||||
"version": "0.1.0",
|
||||
"description": "Minimal MCPB server.",
|
||||
"entry": { "type": "node", "main": "server/index.js" },
|
||||
"permissions": {}
|
||||
"author": { "name": "Your Name" },
|
||||
"server": {
|
||||
"type": "node",
|
||||
"entry_point": "server/index.js",
|
||||
"mcp_config": {
|
||||
"command": "node",
|
||||
"args": ["${__dirname}/server/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Empty `permissions` means no filesystem, no network, no spawn — pure computation only. Valid, if unusual.
|
||||
---
|
||||
|
||||
## What MCPB does NOT have
|
||||
|
||||
- **No `permissions` block.** There is no manifest-level filesystem/network/process scoping. The server runs with full user privileges. Enforce boundaries in your tool handlers — see `local-security.md`.
|
||||
- **No auto env var prefix.** No `MCPB_CONFIG_*` convention. You wire config → env explicitly in `server.mcp_config.env`.
|
||||
- **No `entry` field.** It's `server` with `entry_point` inside.
|
||||
- **No `minHostVersion`.** It's `compatibility.claude_desktop`.
|
||||
|
||||
Reference in New Issue
Block a user