Publishing widgets as MCP Apps
Every published Robutler widget is automatically exposed as an MCP App (SEP-1865), discoverable and consumable by external hosts like Claude, ChatGPT and any MCP-UI client — no Robutler-specific SDK required on the consuming side.
Discovery
External hosts crawl /.well-known/mcp/widgets.json on your portal
origin:
GET https://your-portal.example.com/.well-known/mcp/widgets.json?page=1&per_page=100The catalog is paginated (max 100 per page), sorted by
install_count DESC by default, and ships Link: rel=next,
X-Total-Count, an ETag and Cache-Control: public, max-age=60.
Crawlers should respect the cache headers — daily polls are cheap;
hammering the catalog earns rate-limit pushback.
Each entry points at the per-widget manifest:
GET https://your-portal.example.com/api/widgets/{postId}/mcpThe manifest uses absolute URLs for resourceUri and tool
endpoints so external hosts can hand them to a user without any
base-URL resolution. Private widgets return 404 (not 403) — there
is no way to enumerate unlisted post IDs.
Tool invocation
POST https://your-portal.example.com/api/widgets/{postId}/mcp/tools/{toolName}
Content-Type: application/json
Idempotency-Key: 11111111-2222-4333-8444-555555555555
Authorization: Bearer <token> (when the tool requires auth)
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "daily", "arguments": { "date": "2026-05-26" } }
}Idempotency-Key (required)
Every POST MUST carry an Idempotency-Key header containing a UUID
v4. The server dedupes on (idempotencyKey, postId, toolName) with a
24-hour TTL in Redis.
| Response class | Replays return cached? |
|---|---|
| 2xx | yes (24h) |
| 4xx | yes (24h) — retries are deterministic |
| 5xx | no — the next retry gets a fresh attempt |
Cached replays return the original status + body with an
X-Cached-Response: true header. The tool is NOT re-invoked, the
attribution counter is NOT bumped, and any billable charge is NOT
re-applied.
There is a 24-hour migration window during which missing keys are
logged + warned rather than rejected. After the window flips, a
missing key returns 400 idempotency_key_required — adopt the header
now to avoid surprises.
Visibility gating
A tool is invokable only when its _meta.ui.visibility includes
'app'. The default visibility is ['app'] — model-driven and
human-driven invocations require explicit opt-in:
| Visibility token | Allows |
|---|---|
'app' | calls from the widget's own UI |
'model' | calls from an agent / LLM |
'user' | surfacing in human-facing tool palettes |
A tool declared visibility: ['model'] rejects calls from the widget
UI; a tool with the default ['app'] rejects calls from an agent.
Billing
If a tool declares _meta.robutler.billing.{currency, amountNanocents}
the platform writes a payment_charges row via the existing
payment-token settlement (idempotent on
billing:<userId|anonHash>:mcp-invoke:<postId>:<toolName>:<callId>).
Tools without billing are free and record only attribution.
Cached replays do not re-bill.
Rate limits
- 100 req/min per bearer token.
- 1000 req/min aggregate per widget post.
Over-limit responses return 429 with Retry-After.
Worked example — Claude consuming your widget
- Claude reads
/.well-known/mcp/widgets.json, findsposts/abc123/mcpin the listing. - Claude fetches the manifest, sees a
dailytool withinputSchema: { type: 'object', properties: { date: { type: 'string' } } }. - Claude calls the tool:
curl -X POST https://portal.example.com/api/widgets/abc123/mcp/tools/daily \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: 11111111-2222-4333-8444-555555555555' \ -H 'Authorization: Bearer <claude-issued-token>' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"daily","arguments":{"date":"2026-05-26"}}}' - The platform validates the bearer token (
widget:invokescope), checks visibility, checks the rate limit, dedupes on theIdempotency-Key, invokes the tool, settles billing if declared, and returns the JSON-RPCresult. - A network blip prompts Claude to retry with the same
Idempotency-Key. The platform returns the cached body withX-Cached-Response: true; no double-invoke, no double-bill.
Consumption from ChatGPT and MCP-UI
ChatGPT App SDK consumers can talk to the same endpoint — the wire
shape is identical. Inside the widget iframe, the SDK exposes a
window.openai shim so widget code authored against the ChatGPT App
SDK runs unchanged.
MCP-UI clients consume the manifest directly. The widget iframe also
exposes Claude's App class (import { App } from '/widgets/sdk.v2.js')
so artifact-style consumption works out of the box.
Manifest freshness
When you republish a widget, the manifest snapshot is refreshed
synchronously. If the source widget.json has been updated by other
means and the cached snapshot is stale, the GET manifest route serves
the cached version and emits mcp_widget_refresh_needed_total; a
background cron (scripts/refresh-stale-widget-manifests.ts) re-
snapshots every 6 hours.
See also: authoring.md for the manifest schema
and tool declaration syntax; ADR-v3-16
for the contract details.