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:
Den Delimarsky
2026-03-18 22:53:38 +00:00
parent f7ba55786d
commit 48a018f27a
13 changed files with 1195 additions and 409 deletions

View File

@@ -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

View File

@@ -1,99 +1,120 @@
# Apps SDKWidget ↔ Host Message Protocol
# ext-apps messagingwidget ↔ 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.

View File

@@ -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>
```

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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`

View File

@@ -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`) |

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View 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`.