Skip to main content
Glama
primevalsoup

mcp-apps-claude-demo

by primevalsoup
README.md
# mcp-apps-claude-demo

A tiny, dependency-free MCP server that properly renders inline in Claude (plus ChatGPT and Grok) from a plain custom connector. There's just one Node file (`server.mjs`) and one HTML file (`widget.html`), with each field commented with *why* it's there.

I built this with Claude after burning a good chunk of today figuring out why a spec-perfect MCP Apps widget renders fine in the SDK's `basic-host` and in ChatGPT but shows **nothing** in claude.ai besides the text fallback *"[This tool call rendered an interactive widget in the chat…]"*. That's the exact symptom in [ext-apps#671](https://github.com/modelcontextprotocol/ext-apps/issues/671). This should serve as a simple recipe for others who hit the same problem.

## Run it

```bash
npm start                       # http://localhost:8787/mcp   (open, no auth)
# optional bearer:  AUTH_TOKEN=secret npm start
```

Expose it publicly with any tunnel (or just deploy the single file). Cloudflare quick tunnels need no account:

```bash
cloudflared tunnel --url http://localhost:8787
# -> https://random-words-1234.trycloudflare.com
```

In **claude.ai → Settings → Connectors → Add custom connector**, paste the URL **with the `/mcp` path**:

```
https://random-words-1234.trycloudflare.com/mcp
```

Ask: *"use the demo connector to get the stats"* → a bar-chart widget renders inline.

> Don't want to install a tunnel? The server is a single dependency-free file, so you can deploy it to any free host (Deno Deploy, Val Town, a Cloudflare Worker, Railway…) and skip the tunnel entirely. Or use `npx localtunnel --port 8787`, which needs only Node.

---

## The gotchas, i.e. why claude.ai wouldn't render

Everything below is required for claude.ai even though the SEP-1865 spec marks some of it optional or leaves it implicit. A server can do capability negotiation, `resources/read`, and `tools/call` all correctly and *still* show nothing. These are in the likely order you'll hit them.

### 0. Connection: echo the client's protocol version

Before rendering is even on the table, the connector has to *connect*. claude.ai speaks base-MCP **`2025-11-25`**. If your `initialize` response hardcodes a different `protocolVersion`, claude.ai rejects it with a generic **"Couldn't connect to the server."** Echo back `params.protocolVersion`.

> This is the *transport* version. Don't confuse it with the MCP Apps *widget* handshake version (`2026-01-26`), which is a separate layer inside the iframe.

### 1. `ui.domain`, the undocumented render gate

On the **resource's** `_meta.ui`, claude.ai requires:

```
ui.domain == sha256("<your MCP endpoint URL, incl. /mcp>")[:32] + ".claudemcpcontent.com"
```

- **Deterministic and self-computable.** It is *not* an Anthropic-issued credential and there is *no* partner allowlist. Any server can compute its own.
- Omit it (spec says it's optional) and claude.ai silently fetches your resource, tells the model a widget rendered, and never places the iframe.
- Send a *wrong-format* value and claude.ai gives you an explicit error, **"ui.domain mismatch: expected `<hash>.claudemcpcontent.com`, but got `…`"**, which conveniently prints the value it wants. That expected value is `sha256(connector URL)`.
- The endpoint string matters exactly: `https://host/mcp`, with the `/mcp` path, **no trailing slash**.

This server derives it from the request `Host` header (`widgetDomain()` in `server.mjs`), so it's automatically correct for whatever tunnel or deploy URL you end up with.

### 2. Send `ui/notifications/initialized` unconditionally

This was the final blocker and the least obvious. **claude.ai keeps the widget iframe reserved-but-hidden until the view sends `ui/notifications/initialized`.** You'll see empty reserved space where the widget should be, and not even your static HTML paints.

The trap: send `initialized` only *after* you match a specific `ui/initialize` **response** shape, and you can deadlock forever. The host waits for `initialized`, your view waits for a response it doesn't recognize. Fix: send it on *any* result-bearing reply, **and** on a short unconditional timeout fallback. See `widget.html`.

> ChatGPT renders without this because `window.openai` injects tool output directly and skips the handshake. Implement the real handshake; treat `window.openai` as a compatibility layer.

### 3. `size-changed` params must be numbers

claude.ai validates `ui/notifications/size-changed`; a `width: null` (or missing) throws an **uncaught** error that then breaks handling of the tool call whose widget sent it. Always send real numbers.

### 4. Declare the widget on the tool *twice*

Servers that render in claude.ai put the resource URI on the tool `_meta` in **both** forms:

```json
"_meta": { "ui": { "resourceUri": "ui://demo/stats" }, "ui/resourceUri": "ui://demo/stats" }
```

The nested `ui.resourceUri` is the spec form; the flat `ui/resourceUri` is what claude.ai's current implementation actually reads.

### 5. mimeType is `text/html;profile=mcp-app`

Required on both the `resources/list` entry and the `resources/read` contents. Heads-up if you use the official SDKs: their `Resource` model may reject the parameterized mime type (`;profile=…`), so you'll have to set it at a lower level.

### 6. CORS / OPTIONS

Handle the preflight and send permissive CORS headers, or the connect can fail before any JSON-RPC is exchanged.

### 7. Version your `ui://` URIs, and keep old ones servable

Hosts cache the widget bundle keyed by its `ui://` URI. When you change the bundle, mint a **new** URI or the host serves the stale one. But don't *remove* old URIs. Clients cache the tool declaration independently and re-fetch on their own schedule; a vanished URI shows users **"Failed to fetch template."** Serve the current bundle under all prior URIs.

---

## Other hosts

The same server renders in **ChatGPT** and **Grok** as well. Three independent implementations, same widget, plain custom connectors, no allowlist anywhere. Differences worth knowing:

- **ChatGPT** skips the `initialized` handshake (`window.openai` bridge), so it rendered even before I fixed gotcha #2. Its connector page flags **Widget CSP** and **Widget domain** as required "for app submission," so #1 and a `csp` block matter there too. It reads the ratified `_meta.ui.resourceUri`, not the legacy `openai/outputTemplate`.
- **Grok** renders it out of the box.
- **claude.ai** is the strictest, and it's the one that needs every gotcha above, which is why they're written from its perspective.

The takeaway: build to the strict (claude.ai) target and you render everywhere.

## Scope

- **Is:** the minimal, transparent, *verified-rendering* recipe.
- **Isn't:** production auth (bearer or open only, so keep it behind a tunnel you tear down), the full spec surface, or a framework example. No unit tests by design; the value is the annotated code plus these notes.

MIT.