mcp-apps-claude-demo
Enables rendering of interactive MCP widgets inline in ChatGPT conversations.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-apps-claude-demouse the demo connector to get the stats"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 startExpose 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.comIn claude.ai → Settings → Connectors → Add custom connector, paste the URL with the /mcp path:
https://random-words-1234.trycloudflare.com/mcpAsk: "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 issha256(connector URL).The endpoint string matters exactly:
https://host/mcp, with the/mcppath, 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.openaiinjects tool output directly and skips the handshake. Implement the real handshake; treatwindow.openaias 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
initializedhandshake (window.openaibridge), 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 acspblock matter there too. It reads the ratified_meta.ui.resourceUri, not the legacyopenai/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.
This server cannot be installed
Maintenance
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
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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