Skip to main content
Glama
primevalsoup

mcp-apps-claude-demo

by primevalsoup

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. This should serve as a simple recipe for others who hit the same problem.

Run it

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:

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.


Related MCP server: MCP from Scratch Server

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:

"_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.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/primevalsoup/mcp-apps-claude-demo'

If you have feedback or need assistance with the MCP directory API, please join our Discord server