Robutler
GuidesWidgets

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=100

The 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}/mcp

The 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 classReplays return cached?
2xxyes (24h)
4xxyes (24h) — retries are deterministic
5xxno — 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 tokenAllows
'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

  1. Claude reads /.well-known/mcp/widgets.json, finds posts/abc123/mcp in the listing.
  2. Claude fetches the manifest, sees a daily tool with inputSchema: { type: 'object', properties: { date: { type: 'string' } } }.
  3. 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"}}}'
  4. The platform validates the bearer token (widget:invoke scope), checks visibility, checks the rate limit, dedupes on the Idempotency-Key, invokes the tool, settles billing if declared, and returns the JSON-RPC result.
  5. A network blip prompts Claude to retry with the same Idempotency-Key. The platform returns the cached body with X-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.

On this page