Explorar o código

mcp-server-dev: hosting, payload-cap, lifecycle, and directory guidance (#1566)

Tobin South hai 4 meses
pai
achega
f4b5494fb4

+ 37 - 6
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 `<a target="_blank">` 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

+ 60 - 0
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:<addr>"`), 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.

+ 71 - 4
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 `<a download>`). `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: <title>…") 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" — 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:
 
-There's no explicit "submit and close" — the widget is a long-lived surface.
+```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);
+}
+```
 
 ---
 

+ 18 - 0
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.

+ 24 - 9
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

+ 54 - 0
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.