mcp-apps-claude-demo

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.

Category
Visit Server

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

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured