From 351dfafced11addf76e9f8d48853ea688cd73e57 Mon Sep 17 00:00:00 2001 From: tobin Date: Thu, 23 Apr 2026 05:42:41 +0000 Subject: [PATCH] mcp-server-dev: hosting, payload-cap, lifecycle, and directory guidance --- .../skills/build-mcp-app/SKILL.md | 43 +++++++++-- .../references/abuse-protection.md | 60 +++++++++++++++ .../references/apps-sdk-messages.md | 75 ++++++++++++++++++- .../references/directory-checklist.md | 18 +++++ .../references/iframe-sandbox.md | 33 +++++--- .../references/payload-budgeting.md | 54 +++++++++++++ 6 files changed, 264 insertions(+), 19 deletions(-) create mode 100644 plugins/mcp-server-dev/skills/build-mcp-app/references/abuse-protection.md create mode 100644 plugins/mcp-server-dev/skills/build-mcp-app/references/directory-checklist.md create mode 100644 plugins/mcp-server-dev/skills/build-mcp-app/references/payload-budgeting.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 1afbf39..c400abd 100644 --- a/plugins/mcp-server-dev/skills/build-mcp-app/SKILL.md +++ b/plugins/mcp-server-dev/skills/build-mcp-app/SKILL.md @@ -14,10 +14,15 @@ The UI layer is **additive**. Under the hood it's still tools, resources, and th ## Claude host specifics -- `_meta.ui.prefersBorder: false` on a `ui://` resource removes the outer card border (mobile). +| `_meta.ui.*` key | Where | Effect | +|---|---|---| +| `resourceUri` | tool | Which `ui://` resource the host renders for this tool's results. | +| `visibility: ["app"]` | tool | Hide a widget-only helper tool (e.g. geometry/image fetcher called via `callServerTool`) from Claude's tool list. | +| `prefersBorder: false` | resource | Drop the host's outer card border (mobile). | +| `csp.{connectDomains, resourceDomains, baseUriDomains}` | resource | Declare external origins; default is block-all. `frameDomains` is currently restricted in Claude. | + - `hostContext.safeAreaInsets: {top, right, bottom, left}` (px) — honor these for notches and the composer overlay. -- `_meta.ui.csp.{connectDomains, resourceDomains, baseUriDomains}` — declare external origins per resource; default is block-all. `frameDomains` is currently restricted in Claude. -- Directory submission for MCP Apps requires 3–5 PNG screenshots, ≥1000px wide, cropped to the app response only (no prompt in the image). See https://claude.com/docs/connectors/building/submission#asset-specifications. +- Directory submission requires OAuth or **authless** (`none`) — static bearer is private-deploy only and blocks listing — plus tool `annotations` and 3–5 PNG screenshots; see `references/directory-checklist.md`. --- @@ -104,6 +109,7 @@ 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", + annotations: { title: "Pick Contact", readOnlyHint: true }, inputSchema: { filter: z.string().optional() }, _meta: { ui: { resourceUri: "ui://widgets/contact-picker.html" } }, }, async ({ filter }) => { @@ -172,7 +178,10 @@ The `/*__EXT_APPS_BUNDLE__*/` placeholder gets replaced by the server at startup | `app.updateModelContext({...})` | Widget → host | Update context silently (no visible message) | | `app.callServerTool({name, arguments})` | Widget → server | Call another tool on your server | | `app.openLink({url})` | Widget → host | Open a URL in a new tab (sandbox blocks `window.open`) | -| `app.getHostContext()` / `app.onhostcontextchanged` | Host → widget | Theme (`light`/`dark`), locale, etc. | +| `app.getHostContext()` / `app.onhostcontextchanged` | Host → widget | Theme, host CSS vars, `containerDimensions`, `displayMode`, `deviceCapabilities` | +| `app.requestDisplayMode({mode})` | Widget → host | Ask for `inline` / `pip` / `fullscreen` | +| `app.downloadFile({name, mimeType, content})` | Widget → host | Host-mediated download (base64 content) | +| `new App(info, caps, {autoResize: true})` | — | Iframe height tracks rendered content | `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. `openLink` is **required** for any outbound navigation — `window.open` and `` are blocked by the sandbox attribute. @@ -225,6 +234,7 @@ const pickerHtml = readFileSync("./widgets/picker.html", "utf8") registerAppTool(server, "pick_contact", { description: "Open an interactive contact picker. User selects one contact.", + annotations: { title: "Pick Contact", readOnlyHint: true }, inputSchema: { filter: z.string().optional().describe("Name/email prefix filter") }, _meta: { ui: { resourceUri: "ui://widgets/picker.html" } }, }, async ({ filter }) => { @@ -348,6 +358,24 @@ Desktop caches UI resources aggressively. After editing widget HTML, **fully qui The `sleep` keeps stdin open long enough to collect all responses. Parse the jsonl output with `jq` or a Python one-liner. +**Widget dev loop** — avoid the ⌘Q-relaunch cycle entirely by serving the inlined widget HTML at a plain GET route with a fake `ExtApps` shim that fires `ontoolresult` from a query param: + +```ts +app.get("/widget-preview", (_req, res) => { + const shim = `globalThis.ExtApps={applyHostStyleVariables:()=>{},App:class{ + constructor(){this.h={}} ontoolresult;onhostcontextchanged; + async connect(){const p=new URLSearchParams(location.search).get("payload"); + if(p)this.ontoolresult?.({content:[{type:"text",text:p}]});} + getHostContext(){return{theme:"light"}} + sendMessage(m){console.log("sendMessage",m)} updateModelContext(){} + callServerTool(){return Promise.resolve({content:[]})} openLink(){} downloadFile(){} + }};`; + res.type("html").send(widgetHtml.replace("/*__EXT_APPS_BUNDLE__*/", shim)); +}); +``` + +Open `http://localhost:3000/widget-preview?payload={"rows":[...]}` in a normal browser tab and iterate with ordinary devtools. + **Host fallback** — use a host without the apps surface (or MCP Inspector) and confirm the tool's text content degrades gracefully. **CSP debugging** — open the iframe's own devtools console. CSP violations are the #1 reason widgets silently fail (blank rectangle, no error in the main console). See `references/iframe-sandbox.md`. @@ -356,6 +384,9 @@ The `sleep` keeps stdin open long enough to collect all responses. Parse the jso ## Reference files -- `references/iframe-sandbox.md` — CSP/sandbox constraints, the bundle-inlining pattern, image handling +- `references/iframe-sandbox.md` — CSP/sandbox constraints, the bundle-inlining pattern, image handling, host theming - `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 +- `references/apps-sdk-messages.md` — the `App` class API: widget ↔ host ↔ server messaging, lifecycle & supersession +- `references/payload-budgeting.md` — host tool-result size caps, prune-then-truncate, heavy assets via `callServerTool` +- `references/abuse-protection.md` — Anthropic egress CIDRs, tiered rate limiting, `trust proxy`, response caching +- `references/directory-checklist.md` — pre-flight for connector-directory submission diff --git a/plugins/mcp-server-dev/skills/build-mcp-app/references/abuse-protection.md b/plugins/mcp-server-dev/skills/build-mcp-app/references/abuse-protection.md new file mode 100644 index 0000000..d383b5a --- /dev/null +++ b/plugins/mcp-server-dev/skills/build-mcp-app/references/abuse-protection.md @@ -0,0 +1,60 @@ +# Abuse protection for authless hosted servers + +An authless StreamableHTTP server is reachable by anything on the internet. +There are three resources to protect: your compute, any upstream API quota +your tools consume, and egress bandwidth for large `callServerTool` payloads. + +## You don't get a per-user identity + +In authless mode there is no token and stateless transport gives no session +ID. Traffic from claude.ai is proxied through Anthropic's egress — every web +user arrives from the same small set of IPs: + +``` +160.79.104.0/21 +2607:6bc0::/48 +``` + +(See https://platform.claude.com/docs/en/api/ip-addresses.) + +Claude Desktop, Claude Code, and other hosts connect **directly from the +user's machine**, so those *do* have distinct per-user IPs. Per-IP limiting +therefore works for direct-connect clients; for claude.ai you can only limit +the aggregate Anthropic pool. If true per-user limits matter, that's the +trigger to add OAuth. + +## Tiered token-bucket (per-replica backstop) + +```ts +const ANTHROPIC_CIDRS = ["160.79.104.0/21", "2607:6bc0::/48"]; +const TIERS = { + anthropic: { capacity: 600, refillPerSec: 100 }, // shared pool + other: { capacity: 30, refillPerSec: 2 }, // per-IP +}; +``` + +Match `req.ip` against the CIDRs, pick a bucket (`"anthropic"` or +`"ip:"`), 429 + `Retry-After` on exhaust. This is a per-replica +backstop — cross-replica enforcement belongs at the edge (Cloudflare, Cloud +Armor), which keeps the containers stateless. + +## `trust proxy` must match your topology + +`req.ip` only honours `X-Forwarded-For` if `app.set('trust proxy', N)` is +set. `true` trusts every hop, which lets a direct client send +`X-Forwarded-For: 160.79.108.42` and claim the Anthropic tier. Set it to the +exact number of trusted hops (e.g. `1` behind a single LB, `2` behind +Cloudflare → origin LB) and **never `true` in production**. + +## Hard-allowlisting Anthropic IPs is a product decision + +Blocking everything outside `160.79.104.0/21` locks out Desktop, Claude Code, +and every other MCP host. Use the CIDRs to **tier** rate limits, not to gate +access, unless claude.ai-only is an explicit goal. + +## Cache upstream responses + +For tools that wrap a third-party API, an in-process LRU keyed on the +normalized query (TTL hours, no secrets in the key) is the primary cost +control — repeat queries become free and absorb thundering-herd. Rate limits +are the safety net, not the first line. 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 5894e49..fc1fdbb 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 @@ -2,6 +2,18 @@ The `@modelcontextprotocol/ext-apps` package provides the `App` class (browser side) and `registerAppTool`/`registerAppResource` helpers (server side). Messaging is bidirectional and persistent. +## Construction + +```js +const app = new App( + { name: "MyWidget", version: "1.0.0" }, + {}, // capabilities + { autoResize: true }, // options +); +``` + +`autoResize: true` wires a `ResizeObserver` that emits `ui/notifications/size-changed` so the host iframe height tracks your rendered content. Without it the frame is fixed-height and tall renders get clipped — set it for any widget whose height depends on data. + --- ## Widget → Host @@ -63,6 +75,26 @@ card.querySelector("a").addEventListener("click", (e) => { Host-mediated download (sandbox blocks direct ``). `content` is a base64 string. +```js +const csv = rows.map((r) => Object.values(r).join(",")).join("\n"); +app.downloadFile({ + name: "export.csv", + mimeType: "text/csv", + content: btoa(unescape(encodeURIComponent(csv))), +}); +``` + +### `app.requestDisplayMode({ mode })` + +Ask the host to switch the widget between `"inline"`, `"pip"`, or `"fullscreen"`. Check `getHostContext().availableDisplayModes` first; hide the control if the mode isn't offered. The host responds by firing `onhostcontextchanged` with new `displayMode` and `containerDimensions` — re-render at the new size. + +```js +if (app.getHostContext()?.availableDisplayModes?.includes("fullscreen")) { + expandBtn.hidden = false; + expandBtn.onclick = () => app.requestDisplayMode({ mode: "fullscreen" }); +} +``` + --- ## Host → Widget @@ -84,9 +116,22 @@ app.ontoolresult = ({ content }) => { 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). +### `app.ontoolinputpartial = ({ arguments }) => {...}` / `app.ontoolcancelled = () => {...}` + +`ontoolinputpartial` fires while Claude is still streaming arguments — use it to show a skeleton ("Preparing: …") before the result lands. `ontoolcancelled` fires if the call is aborted; clear the skeleton. + ### `app.getHostContext()` / `app.onhostcontextchanged = (ctx) => {...}` -Read and subscribe to host context — `theme` (`"light"` / `"dark"`), locale, etc. Call `getHostContext()` **after** `connect()`. Subscribe for live updates (user toggles dark mode mid-conversation). +Read and subscribe to host context. Call `getHostContext()` **after** `connect()`. Subscribe for live updates (user toggles dark mode, expands to fullscreen). + +| `ctx.` field | Use | +|---|---| +| `theme` | `"light"` / `"dark"` — toggle a `.dark` class | +| `styles.variables` | Host CSS tokens — pass to `applyHostStyleVariables()` so colors/fonts match host chrome | +| `displayMode` / `availableDisplayModes` | Current mode and which `requestDisplayMode` targets are valid | +| `containerDimensions.{maxHeight,width}` | Size your render to this instead of hard-coded px | +| `deviceCapabilities.touch` | Switch hover-only affordances to tap (`pointerdown`) | +| `safeAreaInsets` | Padding for notches / composer overlay | ```js const applyTheme = (t) => @@ -129,14 +174,36 @@ No `{ notify }` destructure — `extra` is `RequestHandlerExtra`; progress goes ## Lifecycle 1. Claude calls a tool with `_meta.ui.resourceUri` declared -2. Host fetches the resource (your HTML) and renders it in an iframe +2. Host fetches the resource (your HTML) and mounts a **fresh iframe** for this call 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 +7. Iframe persists in the transcript; **the next call to the same tool mounts another iframe** alongside it -There's no explicit "submit and close" — the widget is a long-lived surface. +There's no explicit "submit and close" — each instance is long-lived, but instances are not reused across calls. + +### Supersession + +Because earlier instances stay mounted, a click on a stale widget can `sendMessage` after a newer one has rendered. Detect this with a `BroadcastChannel` and make older instances inert: + +```js +let superseded = false; +const seq = Date.now() + Math.random(); +const bc = new BroadcastChannel("my-widget"); +bc.onmessage = (e) => { + if (e.data?.seq > seq) { + superseded = true; + document.body.classList.add("superseded"); // opacity:.45; pointer-events:none + } +}; +bc.postMessage({ seq }); + +// Guard outbound calls: +function safeSend(msg) { + if (!superseded) app.sendMessage(msg); +} +``` --- diff --git a/plugins/mcp-server-dev/skills/build-mcp-app/references/directory-checklist.md b/plugins/mcp-server-dev/skills/build-mcp-app/references/directory-checklist.md new file mode 100644 index 0000000..3184c72 --- /dev/null +++ b/plugins/mcp-server-dev/skills/build-mcp-app/references/directory-checklist.md @@ -0,0 +1,18 @@ +# Connector-directory submission checklist + +Pre-flight before submitting a remote MCP app to the Claude connector +directory. Each item is a hard review criterion. + +| Area | Requirement | +|---|---| +| **Auth** | OAuth (DCR or CIMD) or **`none`** (authless). Static bearer tokens are private-deploy only and block listing. Authless is valid for public-data servers — the server holds any upstream API keys. | +| **Tool annotations** | Every tool sets `annotations.title` plus the relevant hints: `readOnlyHint: true` for fetch/search tools, `destructiveHint` / `idempotentHint` for writes, `openWorldHint: true` if the tool reaches an external system. | +| **Tool names** | ≤ 64 characters, snake/kebab case. | +| **Widget layout** | Inline height ≤ 500px, no nested scroll containers, 44pt minimum touch targets, WCAG-AA contrast in both themes. | +| **Theming** | `html, body { background: transparent }`, `<meta name="color-scheme" content="light dark">`, adopt host CSS tokens via `applyHostStyleVariables`. | +| **External links** | Use `app.openLink`. Declare each origin (e.g. `https://api.example.com`) in the connector's *Allowed link URIs* so the link skips the confirm modal. | +| **Helper tools** | Widget-only tools (geometry/image fetchers) carry `_meta.ui.visibility: ["app"]` so they don't appear in Claude's tool list. | +| **Screenshots** | 3–5 PNGs, ≥ 1000px wide, cropped to the app response only — no prompt text in frame. | + +See `abuse-protection.md` for rate-limit and IP-tiering guidance once the +authless endpoint is public. diff --git a/plugins/mcp-server-dev/skills/build-mcp-app/references/iframe-sandbox.md b/plugins/mcp-server-dev/skills/build-mcp-app/references/iframe-sandbox.md index 9577b25..6a7e2a1 100644 --- a/plugins/mcp-server-dev/skills/build-mcp-app/references/iframe-sandbox.md +++ b/plugins/mcp-server-dev/skills/build-mcp-app/references/iframe-sandbox.md @@ -122,23 +122,38 @@ that survives un-inlined. --- -## Dark mode +## Theme & host styles -```js -const applyTheme = (theme) => - document.documentElement.classList.toggle("dark", theme === "dark"); +The host renders the iframe inside its own card chrome — paint a **transparent** background and adopt host CSS tokens so the widget blends in across light/dark and across hosts. -app.onhostcontextchanged = (ctx) => applyTheme(ctx.theme); -await app.connect(); -applyTheme(app.getHostContext()?.theme); +```html +<meta name="color-scheme" content="light dark" /> ``` ```css -:root { --ink:#0f1111; --bg:#fff; color-scheme:light; } -:root.dark { --ink:#e6e6e6; --bg:#1f2428; color-scheme:dark; } +:root { + --ink: var(--color-text-primary, #0f1111); + --sub: var(--color-text-secondary, #5a6270); + --line: var(--color-border-default, #e3e6ea); +} +html, body { background: transparent; color: var(--ink); } :root.dark .thumb { mix-blend-mode: normal; } /* multiply → images vanish in dark */ ``` +```js +const { App, applyHostStyleVariables } = globalThis.ExtApps; + +function applyHostContext(ctx) { + document.documentElement.classList.toggle("dark", ctx?.theme === "dark"); + if (ctx?.styles?.variables) applyHostStyleVariables(ctx.styles.variables); +} +app.onhostcontextchanged = applyHostContext; +await app.connect(); +applyHostContext(app.getHostContext()); +``` + +`applyHostStyleVariables` writes the host's `--color-*` / `--font-*` / `--border-radius-*` tokens onto `:root`; the hex values above are fallbacks for hosts that don't supply them. + --- ## Debugging diff --git a/plugins/mcp-server-dev/skills/build-mcp-app/references/payload-budgeting.md b/plugins/mcp-server-dev/skills/build-mcp-app/references/payload-budgeting.md new file mode 100644 index 0000000..a005173 --- /dev/null +++ b/plugins/mcp-server-dev/skills/build-mcp-app/references/payload-budgeting.md @@ -0,0 +1,54 @@ +# Payload budgeting + +Hosts cap tool-result text. claude.ai and Claude Desktop truncate at roughly +**150,000 characters**; Claude Code at ~25k tokens. When a tool result exceeds +the cap, the host substitutes a file-pointer string in place of your JSON. The +widget then receives non-JSON in `ontoolresult`, `JSON.parse` throws, and the +user sees something like *"Bad payload: SyntaxError: Unexpected token 'E'"* — +with no hint that size was the cause. + +## Symptom → cause + +| Symptom | Likely cause | +|---|---| +| Widget shows a JSON parse error on `content[0].text` | Result over the host cap; host swapped in a file-pointer string | +| Works for one query, breaks for "all of X" | Row count × column count crossed the cap | +| Works in MCP Inspector, breaks in Desktop | Inspector has no cap; Desktop does | + +## Strategy + +Cap your own payload at ~130KB and degrade in order: + +1. **Ship full rows** when `JSON.stringify(rows).length` is under the cap. +2. **Prune columns** to those the rendering spec actually references. Walk the + spec for both `field: "..."` keys *and* `datum.X` / `datum['X']` inside + expression strings — if the spec aliases a column via a `calculate` + transform, the alias appears as `field:` but the source column only appears + as `datum.X`, and dropping it leaves the widget with NaN. +3. **Truncate rows** as a last resort and include `{ truncated: N }` in the + payload so the widget can label it. + +```ts +const MAX = 130_000; +let out = rows; +if (JSON.stringify(out).length > MAX) { + const keep = referencedFields(spec); // field: + datum.X refs + out = rows.map((r) => pick(r, keep)); + if (JSON.stringify(out).length > MAX) { + const per = JSON.stringify(out[0] ?? {}).length || 1; + out = out.slice(0, Math.floor(MAX / per)); + } +} +``` + +## Heavy assets go via `callServerTool`, not the result + +Geometry, image bytes, or any blob the widget needs but Claude doesn't should +be served by a separate tool the widget calls after mount: + +```js +const topo = await app.callServerTool({ name: "get-topojson", arguments: { level } }); +``` + +Mark that helper tool with `_meta.ui.visibility: ["app"]` so it doesn't appear +in Claude's tool list.