Interactive UI widgets for Santiment's MCP server, rendered inside MCP-enabled hosts (Claude, etc.) per the MCP Apps spec.
Each widget is a self-contained bundle served as an MCP UI resource (text/html;profile=mcp-app). The MCP server (Elixir, sanbase2) returns a _meta.ui.resourceUri from its tool — the host fetches the resource, renders it in a sandboxed iframe, and pushes tool results to it via postMessage.
src/
widgets/
social-trends/
App.svelte — UI component
StoryCard.svelte
contract.ts — shape of data exchanged with the server (single source of truth)
main.ts — Svelte mount entry
harness/
Harness.svelte — simulates an MCP host for local dev
mock-data.ts
widgets/
social-trends.html — entry HTML, bundled by Vite
pnpm install
pnpm devOpen http://localhost:5173. The harness is a fake MCP host: it answers ui/initialize, then pushes ui/notifications/tool-input + ui/notifications/tool-result to the widget — same shape Claude sends.
pnpm buildOutput: dist/. Each entry produces hashed asset filenames so the browser cache cannot serve stale code after deploy.
dist/
widgets/social-trends.html — entry HTML with hashed asset references
social-trends/main-<hash>.js — entry script
chunks/bundle-mjs-<hash>.js — shared chunk
assets/bundle-mjs-<hash>.css — styles
assets/ProximaNova-*.woff2 — fonts
dist/widgets/social-trends.html is the canonical artifact — the Elixir server should read this file verbatim and return it as the resources/read body. Do not hand-construct the HTML on the server.
Host dist/ as plain static files on any HTTPS endpoint (S3+CloudFront, GCS, Vercel, nginx, …). Requirements:
- HTTPS with a valid certificate
- Correct MIME types:
application/javascriptfor.js,text/cssfor.css Cache-Control: public, max-age=31536000, immutablefor hashed assets (undersocial-trends/,chunks/,assets/)Cache-Control: no-cacheforwidgets/social-trends.html- CORS headers are not required — all data flows through MCP's postMessage transport
The widget and the server agree on two things:
See src/widgets/social-trends/contract.ts — TrendingStoriesData. Any change here must be mirrored in lib/sanbase/mcp/trending_stories_tool.ex (and, ideally, the tool's output_schema callback).
The Elixir resource module (lib/sanbase/mcp/social_trends_ui.ex) must:
- Return the canonical bundled HTML in
Response.text(...). Readdist/widgets/social-trends.htmlverbatim or fetch it from the deployed URL — do not hand-construct. - Declare CSP allowlist in
_meta.ui.csp.resourceDomains(andconnectDomainsif the widget ever doesfetch). Without this, the default CSP (default-src 'none'; script-src 'self' 'unsafe-inline') blocks all asset loading from the deployment domain. - Set
prefersBorder: true(orfalse, but explicit) in_meta.uiso hosts render a consistent boundary.
Minimal Elixir snippet:
_meta = %{
"ui" => %{
"csp" => %{"resourceDomains" => [base_url]},
"prefersBorder" => true
}
}The harness fakes Claude well enough for UI iteration, but the real host applies origin rewriting and strict CSP. Always do a final check against actual Claude:
# 1. Run sanbase2 (Phoenix) locally on :4000 with the MCP endpoint exposed
cd ../sanbase2 && ./dev_tunnel.sh
# 2. Add the tunnel URL as a custom connector in Claude
# (Settings → Connectors → Add custom connector)
# 3. Invoke the tool from a chat: "Show me the trending stories"If the widget loads but assets 404 → CSP is missing the deployment domain.
If the widget loads but data never arrives → server returns content only, not structuredContent.
If sendMessage from the widget does nothing → Claude declined; check the host log.
Official ext-apps templates use vite-plugin-singlefile and ship the entire HTML+JS+CSS as one inline bundle. We deliberately keep external assets to:
- decouple UI deploys from Elixir releases
- enable CDN edge caching
- iterate on widgets in this repo independently
This costs us one extra mandatory step: the _meta.ui.csp.resourceDomains declaration on the server side. Without it, nothing loads.