From 48a018f27ad64a286739bb105dd3af00eeb685ef Mon Sep 17 00:00:00 2001 From: Den Delimarsky Date: Wed, 18 Mar 2026 22:53:38 +0000 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20mcp-server-dev=20=E2=80=94=20cor?= =?UTF-8?q?rect=20APIs=20against=20spec,=20add=20missing=20primitives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../skills/build-mcp-app/SKILL.md | 267 ++++++++++++------ .../references/apps-sdk-messages.md | 155 +++++----- .../references/widget-templates.md | 161 +++++++---- .../skills/build-mcp-server/SKILL.md | 42 ++- .../build-mcp-server/references/auth.md | 45 ++- .../references/elicitation.md | 129 +++++++++ .../references/remote-http-scaffold.md | 66 +++-- .../references/resources-and-prompts.md | 122 ++++++++ .../references/server-capabilities.md | 164 +++++++++++ .../references/tool-design.md | 67 +++++ .../mcp-server-dev/skills/build-mcpb/SKILL.md | 110 +++++--- .../build-mcpb/references/local-security.md | 52 +++- .../build-mcpb/references/manifest-schema.md | 224 ++++++++------- 13 files changed, 1195 insertions(+), 409 deletions(-) create mode 100644 plugins/mcp-server-dev/skills/build-mcp-server/references/elicitation.md create mode 100644 plugins/mcp-server-dev/skills/build-mcp-server/references/resources-and-prompts.md create mode 100644 plugins/mcp-server-dev/skills/build-mcp-server/references/server-capabilities.md diff --git a/plugins/mcp-server-dev/skills/build-mcp-app/SKILL.md b/plugins/mcp-server-dev/skills/build-mcp-app/SKILL.md index 74dcda4..147f8cb 100644 --- a/plugins/mcp-server-dev/skills/build-mcp-app/SKILL.md +++ b/plugins/mcp-server-dev/skills/build-mcp-app/SKILL.md @@ -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 + +``` + +| 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 -
- - - - -
+ ``` -`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 diff --git a/plugins/mcp-server-dev/skills/build-mcp-app/references/apps-sdk-messages.md b/plugins/mcp-server-dev/skills/build-mcp-app/references/apps-sdk-messages.md index a7f0f07..83093c5 100644 --- a/plugins/mcp-server-dev/skills/build-mcp-app/references/apps-sdk-messages.md +++ b/plugins/mcp-server-dev/skills/build-mcp-app/references/apps-sdk-messages.md @@ -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 ``. The `<` escape prevents `` injection. - --- ## Picker (single-select list) @@ -34,7 +30,6 @@ Every template below hydrates from ``. The `< ```html - ``` -**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 ``. The `< ```html - + + ``` diff --git a/plugins/mcp-server-dev/skills/build-mcp-server/SKILL.md b/plugins/mcp-server-dev/skills/build-mcp-server/SKILL.md index 561f90e..a1fdeea 100644 --- a/plugins/mcp-server-dev/skills/build-mcp-server/SKILL.md +++ b/plugins/mcp-server-dev/skills/build-mcp-server/SKILL.md @@ -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 diff --git a/plugins/mcp-server-dev/skills/build-mcp-server/references/auth.md b/plugins/mcp-server-dev/skills/build-mcp-server/references/auth.md index 78bb8f8..ba7227c 100644 --- a/plugins/mcp-server-dev/skills/build-mcp-server/references/auth.md +++ b/plugins/mcp-server-dev/skills/build-mcp-server/references/auth.md @@ -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. diff --git a/plugins/mcp-server-dev/skills/build-mcp-server/references/elicitation.md b/plugins/mcp-server-dev/skills/build-mcp-server/references/elicitation.md new file mode 100644 index 0000000..d1fede6 --- /dev/null +++ b/plugins/mcp-server-dev/skills/build-mcp-server/references/elicitation.md @@ -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 diff --git a/plugins/mcp-server-dev/skills/build-mcp-server/references/remote-http-scaffold.md b/plugins/mcp-server-dev/skills/build-mcp-server/references/remote-http-scaffold.md index bcf5eb4..af44a0b 100644 --- a/plugins/mcp-server-dev/skills/build-mcp-server/references/remote-http-scaffold.md +++ b/plugins/mcp-server-dev/skills/build-mcp-server/references/remote-http-scaffold.md @@ -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` diff --git a/plugins/mcp-server-dev/skills/build-mcp-server/references/resources-and-prompts.md b/plugins/mcp-server-dev/skills/build-mcp-server/references/resources-and-prompts.md new file mode 100644 index 0000000..52749dd --- /dev/null +++ b/plugins/mcp-server-dev/skills/build-mcp-server/references/resources-and-prompts.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`) | diff --git a/plugins/mcp-server-dev/skills/build-mcp-server/references/server-capabilities.md b/plugins/mcp-server-dev/skills/build-mcp-server/references/server-capabilities.md new file mode 100644 index 0000000..b797f05 --- /dev/null +++ b/plugins/mcp-server-dev/skills/build-mcp-server/references/server-capabilities.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. diff --git a/plugins/mcp-server-dev/skills/build-mcp-server/references/tool-design.md b/plugins/mcp-server-dev/skills/build-mcp-server/references/tool-design.md index 574b238..29ae377 100644 --- a/plugins/mcp-server-dev/skills/build-mcp-server/references/tool-design.md +++ b/plugins/mcp-server-dev/skills/build-mcp-server/references/tool-design.md @@ -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. diff --git a/plugins/mcp-server-dev/skills/build-mcpb/SKILL.md b/plugins/mcp-server-dev/skills/build-mcpb/SKILL.md index 7e6baa3..4334163 100644 --- a/plugins/mcp-server-dev/skills/build-mcpb/SKILL.md +++ b/plugins/mcp-server-dev/skills/build-mcpb/SKILL.md @@ -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.}` 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_`). +**`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. diff --git a/plugins/mcp-server-dev/skills/build-mcpb/references/local-security.md b/plugins/mcp-server-dev/skills/build-mcpb/references/local-security.md index e1455c0..6fd756f 100644 --- a/plugins/mcp-server-dev/skills/build-mcpb/references/local-security.md +++ b/plugins/mcp-server-dev/skills/build-mcpb/references/local-security.md @@ -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 diff --git a/plugins/mcp-server-dev/skills/build-mcpb/references/manifest-schema.md b/plugins/mcp-server-dev/skills/build-mcpb/references/manifest-schema.md index 0e2083d..0a42b84 100644 --- a/plugins/mcp-server-dev/skills/build-mcpb/references/manifest-schema.md +++ b/plugins/mcp-server-dev/skills/build-mcpb/references/manifest-schema.md @@ -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_`. - -```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.}` — 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`.