Compare commits

...

1 Commits

Author SHA1 Message Date
tobin
351dfafced mcp-server-dev: hosting, payload-cap, lifecycle, and directory guidance 2026-04-23 05:42:41 +00:00
6 changed files with 264 additions and 19 deletions

View File

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

View File

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

View File

@@ -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" — 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);
}
```
---

View File

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

View File

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

View File

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