mcp-apps-claude-demo
A minimal, dependency-free MCP server that renders interactive widgets (like bar charts) inline in Claude, ChatGPT, and Grok from a custom connector.
README
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.
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.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。