@womp/mcp-ui
v0.1.11
Published
MCP Apps widgets for Womp's MCP server — interactive cards rendered inline in Claude and other MCP hosts.
Keywords
Readme
@womp/mcp-ui
Interactive cards for Womp's MCP server, rendered inline in Claude (and any other MCP Apps host) instead of the default tool-result thumbnail.
Built with MCP Apps — tools
declare a ui:// resource containing an HTML interface, and the host renders it
in a sandboxed iframe, pushing tool data in over postMessage.
generate_image ──► ui://womp/image-<version>.html (280 KB) image card
image_to_mesh ──► ui://womp/mesh-<version>.html (900 KB) 3D GLB viewer
get_generation ──► ui://womp/mesh-<version>.html (copes with either)
estimate_print ──► ui://womp/print-<version>.html (321 KB) the print panel as a card: size, material, finish, colour, style, qty
prepare_print ──► ui://womp/print-<version>.html (static receipt mode)
add_to_cart ──► ui://womp/cart-<version>.html (260 KB) cart rows + checkout link-out
view_cart ──► ui://womp/cart-<version>.html (self-fills thumbnails via app-callable view_cart)
get_print_order ──► ui://womp/order-<version>.html (258 KB) status chips, tracking, diff-charge strip
list_print_orders ─► ui://womp/order-<version>.html
create_upload ──► ui://womp/upload-<version>.html (266 KB) drop zone: the user's mesh file goes to the ticket URL from the chat
get_upload ──► ui://womp/upload-<version>.html (app-callable — the card polls it)What it exports
The package ships the built HTML as a string, not as files:
const {
IMAGE_APP_URI, IMAGE_APP_CSP, IMAGE_APP_HTML,
MESH_APP_URI, MESH_APP_CSP, MESH_APP_HTML,
PRINT_APP_URI, PRINT_APP_CSP, PRINT_APP_HTML,
CART_APP_URI, CART_APP_CSP, CART_APP_HTML,
ORDER_APP_URI, ORDER_APP_CSP, ORDER_APP_HTML,
UPLOAD_APP_URI, UPLOAD_APP_CSP, UPLOAD_APP_HTML,
APP_MIME_TYPE,
} = require("@womp/mcp-ui");No fs.readFile, no __dirname. That is deliberate: swan-backend runs from
build/ on one deploy path and releases/<sha>/ on another, and a path that
resolves in one does not resolve in the other.
Consuming it from swan-backend
import {
IMAGE_APP_URI, IMAGE_APP_CSP, IMAGE_APP_HTML,
MESH_APP_URI, MESH_APP_CSP, MESH_APP_HTML,
APP_MIME_TYPE,
} from "@womp/mcp-ui";
for (const card of [
{ name: "Womp image card", uri: IMAGE_APP_URI, html: IMAGE_APP_HTML, csp: IMAGE_APP_CSP },
{ name: "Womp 3D mesh card", uri: MESH_APP_URI, html: MESH_APP_HTML, csp: MESH_APP_CSP },
]) {
server.registerResource(card.name, card.uri, { description: card.name }, async () => ({
contents: [{ uri: card.uri, mimeType: APP_MIME_TYPE, text: card.html,
_meta: { ui: { csp: card.csp } } }],
}));
}Then on each tool:
_meta: { ui: { resourceUri: IMAGE_APP_URI }, "ui/resourceUri": IMAGE_APP_URI },get_generation additionally needs visibility: ["model", "app"] — see below.
The cards read swan-backend's GENERATION_OUTPUT_SCHEMA: status /
generationId / operationId / imageUrl / glbUrl / previewUrl, plus
model and durationSeconds for the meta line described below. Every field is
optional except status, so a card is safe against a backend that predates any
of them — it just renders less.
Things that will waste your day if you don't know them
Everything here was established empirically against Claude on 2026-09-01.
The bridge is mandatory, not polish
The host mounts the iframe hidden (visibility: hidden; height: 100px) and
reveals it only after the app completes:
host → ui/notifications/sandbox-resource-ready
app → ui/initialize
host → McpUiInitializeResult (carries theme, styles, displayMode)
app → ui/notifications/initialized
app → ui/notifications/size-changed (ResizeObserver)app.connect() does all of it including auto-resize. Skip it and the card
renders perfectly and is never displayed. A static HTML card cannot work.
Handlers must be attached before connect() or they never fire.
CSP: two different fields
_meta.ui.csp.resourceDomains— scripts, styles, images_meta.ui.csp.connectDomains— fetch/XHR, needed for the GLB
The mesh card declares both. Missing connectDomains looks like a broken
viewer, not a blocked request.
Do not also send the flatter _meta.ui.resourceDomains variant that the
package's SKILL.md prose uses. An unrecognised key under ui invalidates the
resource and the widget stops rendering entirely. Only the shape above works.
Host theme variables do not exist on the document
Reading --color-text-primary off documentElement returns nothing. Host
styles arrive via onhostcontextchanged and the app applies them itself
(applyHostStyleVariables). Every var() must ship a fallback.
The complete set a host may send is McpUiStyleVariableKeySchema in
@modelcontextprotocol/ext-apps — anything outside it will never resolve.
Notably it is --color-border-primary, not --color-border.
Hosts cache UI resources per connector
A changed card does not reach an existing connector. Bumping the ui://
URI is not enough — the old URI 404'd server-side while Claude still rendered
its cached copy. The only fix found was remove and re-add the connector.
This is why the version is baked into the URI, and why the first published version should be one you are happy to live with: users will not reconnect on their own.
Widget-composed messages trigger a security warning
app.sendMessage() pre-fills the composer with a red "Malicious conversation
content could trick Claude…" banner. That is correct behaviour — a widget is
untrusted content — and there is no opt-out.
So there is no "Make 3D" button. The affordance lives in generate_image's
response text instead, nudging the model to offer the conversion in prose. Zero
friction, intent still lands in the transcript, and you get a separate mesh
card.
Rule of thumb: apps may call tools to observe (the polling below), but should send messages to act — and acting costs a warning.
Self-updating cards
CallToolRequest is part of AppRequest, so a card can call tools through the
host. The running state uses this: the tool returns immediately with
{ status: "running", operationId }, and the card polls get_generation
itself — 2s backing off to 10s, giving up after 6 minutes, stopping on
teardown.
This is why the conversation is never blocked by a slow generation, and it replaced a day of MCP Tasks work with a polling loop.
get_generation must declare visibility: ["model", "app"]. Without
"app" the host refuses the call and the running state never resolves.
What the card shows about a result
The line under the image is 8.77s · Nano Banana 2 — durationSeconds and
model, formatted the way swan-frontend's generation tile does
(OperationModelTags.tsx), two decimals included.
durationSecondsis the provider's ownstats.generation_time, not wall-clock from the tool call. Queue wait is excluded, which is what makes it the same number the Womp app shows for the same generation. Backend note: forgenerate_imageit comes off the result in hand rather than the DB row, sinceOperation.ext.statsis written after the history resolves.- It is one of two lines, not one.
#metais what produced the result;#statusis when something went wrong. They shared an element once and a preview error erased the model name, or the reverse, depending on which landed last. Both collapse when empty. - There is no generationId chip. It was the first thing on the card and it is an internal id: it means nothing to the reader, and the model gets it from the tool's text response. Removed in 0.2.0.
Display modes
ctx.displayMode is inline | fullscreen | pip. Cards are capped narrow
inline (360px) so they do not wall off the transcript — which made fullscreen
useless until CSS started keying off :root[data-display-mode="fullscreen"].
PiP is untested.
The print card is the web print panel, compressed
src/print/ follows swan-frontend's NewPrintingPanel step for step — Size,
Material (finish and colour controls inside the selection), Print style, then a
review row with quantity and price — so a person who has printed on womp.com
recognises it. Where the card differs, it is because MCP differs:
- Size is W/H/D in cm or inches, but scales uniformly. The tool contract
is
longestSideCm, so editing one axis rescales the other two and the longest side is what gets sent. The panel's 60 × 60 × 40 cm ceiling blocks the button here as it does there. - Colour is a hex, snapped to Pantone. The panel snaps a pick to the
nearest of 2390 Pantone coated shades and sends the code; MCP orders carry a
hex. The card snaps the same way (
src/print/pantone.ts, CIEDE2000 like the panel'scolor-diff) and sends the snapped shade's own hex, so both paths print the same colour and show the same "Pantone 2386 C". The palette is packed intosrc/print/pantone-coated.ts(28 KB) byscripts/gen-pantone.mjs <path to swan-frontend's PantoneCoatedColors.js>; regenerate it if the frontend's list changes. Colour never re-quotes — it never changes the price. - Hollow never orders, and since v1 is not even shown. Escape holes are
placed in the editor, so choosing Hollow shows its price and turns the button
into "Continue on womp.com", which calls
prepare_printand opens the project. The backend refuseshollow: trueoutright; the card never sends it. The whole section is driven by the quote'shollowrow, which swan-backend stopped publishing (gc.mcp.printHollowPricing, off) — so the card hides it with no change of its own, published version included. - Which materials appear is the backend's call. The tiles are the quote's
materialsarray verbatim, and swan-backend now sends only what chat sells (gc.mcp.printMaterials; v1 is Clear + Tinted clear). With no Enterprise entry in the list the disclosure below hides itself — again, no card change. - Gated Enterprise materials stay visible, with a crown, behind a
disclosure. Tapping one keeps the current selection and shows the
contact-sales note (the backend's
contactUrl) — the panel's dialog, flattened. - Tool errors are rewritten for a person. "resin-clear-tinted is a dyed material — pass a color as a #rrggbb hex" is for the model; the card says "Pick a colour for this material first."
What it reads off estimate_print beyond the price rows: material (the
catalog KEY it sent — the backend echoes it unchanged; echoing the priced id
snapped Tinted clear back to Clear on every re-quote), materialLabel,
finish, color, repaired / nonManifold, and a role-aware materials
list with swatchUrl, finishes, colorRequired / colorOptional,
available and contactUrl.
The card's knob turns must be published, or the model orders the defaults.
A card's own tool calls go host → server → iframe and never enter the
conversation, so the model's view of the print is frozen at the arguments it
opened the card with: tune it, ask for the cart in chat, and you get a 10 cm
white one. So every change (markDirty(), which every control already goes
through) schedules a syncConfig() that does two things:
- calls
sync_print_config— an app-ONLY tool (visibility: ["app"], kept out of the model's tool list) that parks the selection server-side, whereadd_to_cartandprepare_printread it. This is the load-bearing half. Colour and finish changes do not re-quote, soestimate_printcould not have been the sync point. - calls
updateModelContextviapushModelContext(shared/bridge.ts), which is the MCP Apps channel for the same fact and lets the model talk about the right print. Host-gated, and Claude web was reported to drop the payload (claude-ai-mcp#102), so it is a bonus, never the mechanism. The harness advertises the capability and logs what arrives.
Layout trap that cost an hour: a grid container's implicit auto track
grows to its items' min-content, and a bare <input type="number"> is ~150px
wide intrinsically — three of them widened every row past the card's edge while
the card itself stayed 360px. Every grid in the card is
grid-template-columns: minmax(0, 1fr) and the inputs have width: 0 as their
flex basis. If a row overflows, look there first.
The stub takes five switches for the states a default account never shows:
FAKE_ENTERPRISE=1 (Enterprise materials orderable), FAKE_PRO=1 (hollow
priced as available), FAKE_REPAIRED=1 (the repaired / non-manifold callouts),
FAKE_ALL_MATERIALS=1 (the whole catalog, i.e. before the v1 narrowing) and
FAKE_HOLLOW=1 (quotes carry the hollow row again, which brings the card's
Solid/Hollow section back). The last two default to what swan-backend now
defaults to, so the card meets the shipped contract here.
It also keeps the last sync_print_config and merges it into add_to_cart /
prepare_print the way the server does — module scope, not inside
buildServer(), which runs per request exactly as swan-backend's does.
The upload card sends bytes from inside the iframe
src/upload/ exists because MCP has no client→server file channel and a chat
host's sandbox usually cannot reach Womp — so a file the user had just attached
to the chat could only reach the print pipeline via a womp.com page. The card
is that page, in the conversation: drop zone + picker, XHR PUT of the raw file
to the ticket URL swan-backend minted (create_upload), progress, the server's
own refusal text on 4xx, 409/410 terminal. It never touches the model: the
bytes go disk → API.
Two things outside this repo make that PUT possible, and both fail as a bare network error inside the card if missing (the status line names that layer, since there is no console in the sandbox):
connectDomainsmust name the API origin, and the origin differs per environment — sobake-html.mjsbakes{}for this card and swan-backend completes the CSP with its own origin at read time. The dev stub does the same withhttp://localhost:3001.- The backend answers with open CORS on
/mcp/v1/uploadsbecause a sandboxed iframe's origin is opaque (Origin: null). The ticket in the URL is the security boundary, not the origin.
While pending the card polls the app-callable get_upload, so a file the agent
pushed itself (curl -T from a shell that has it) flips the card to done
instead of leaving a drop zone for a file that already landed.
There is no link out to an upload page. swan-frontend had one at
/upload/<token> before this card existed; it was removed 2026-09-15 (Anuj)
so there is exactly one place a user sends a file from. The recovery for a
blocked PUT is the agent's own curl -T, which its tool text gives it. The dev stub
(dev/server.mjs) carries create_upload / get_upload and a PUT
/mcp/v1/uploads/:token sink with the GLB-magic sniff, so the refusal strip is
reachable locally too.
Development
Do not iterate through Claude. A card change reaches a connector only after an npm publish, a swan-backend bump, a deploy, and a remove-and-re-add of the connector. The repo ships its own host so that loop is only needed to confirm the finished thing.
yarn install
yarn dev:mcp # stub MCP server on :3001 with fake slow generations
yarn dev # vite; opens the harness at /dev/host/index.htmlTwo terminals, then edit src/ and watch the card hot-reload.
Someone else already on :3001? node dev/server.mjs 3002, then open the harness
with ?mcp=http://127.0.0.1:3002/mcp — two sessions, one checkout, no fighting
over the port.
The harness (dev/host/)
A real MCP Apps host: AppBridge from @modelcontextprotocol/ext-apps, driving
the card in an iframe and proxying its tool calls to the stub. Buttons for
card (image/mesh), tool call, theme, and display mode; a log of every message
that crosses the bridge.
It is deliberately faithful where being unfaithful would hide a bug:
| It reproduces | So you catch |
|---|---|
| iframe mounted hidden until initialized + a size report | a card that never calls app.connect() and is invisible in Claude |
| host style variables sent only over the bridge (and a toggle to withhold them) | a var() shipped without a fallback |
| the card's own get_generation polling, proxied to a real MCP server | a running state that never resolves |
| "built resource" mode — the exact HTML resources/read returns, in a sandboxed opaque-origin iframe | an asset vite-plugin-singlefile failed to inline (blank card) |
| updateModelContext advertised and logged, and a stub that remembers sync_print_config across requests | a card whose selection never leaves the iframe — press add_to_cart (it sends the mesh and nothing else) and read what the stub merged |
FAKE_DURATION_MS=8000 yarn dev:mcp lengthens a fake generation, so the
running → polling → complete transition is worth watching. The print stubs
validate like the real tools (dyed material without a colour, Enterprise
material, hollow intent, bad finish) so every refusal the card must render is
reachable locally.
Keep the tab visible. A backgrounded tab does not run
requestAnimationFrame and clamps setTimeout, so auto-resize stops reporting
and the card's polling crawls — both look like card bugs and are not.
Headless/software-rendered browsers may also fail to create a WebGL context, in
which case the mesh card correctly falls back to the still image.
The sandbox CSP is Claude's own and cannot be reproduced here: a blocked
media.womp.com image or GLB only shows up there. The declared CSP is printed
under the stage so it can at least be read against the URLs in play.
Confirming it in Claude
Still needed before shipping a card, because only Claude proves Claude:
ngrok http 3001 --url https://<reserved>.ngrok-free.dev
# add https://<reserved>.ngrok-free.dev/mcp as a custom connectorRemember to remove and re-add the connector after every rebuild.
There is no console inside the sandboxed iframe, so each card carries a status line that names the failing layer instead of going blank. Read it first.
Build pipeline
vite-plugin-singlefile inlines everything into one HTML file — mandatory,
since the sandboxed iframe cannot fetch sibling assets, and it fails
silently (a blank card, no error). scripts/bake-html.mjs refuses to
publish if any <script src> or <link href> survived.
The plugin also disables code splitting, which rollup refuses to combine with
multiple inputs — so the cards build in two separate passes driven by a
CARD env var, not one multi-entry build.
Releasing
Publishing is automated: tag and push, and .github/workflows/publish.yml
does the rest via npm trusted publishing (OIDC) — there is no NPM_TOKEN
secret.
git tag v0.1.1 && git push origin v0.1.1The workflow resolves the version from the tag, writes it into package.json
before building, typechecks, builds, verifies the baked ui:// URIs match
the tag, and publishes. It is a no-op if that version already exists on npm, so
re-running is safe. There is also a workflow_dispatch entry point if you need
to publish without tagging.
Do not commit a bumped version — the tag is the source of truth, and the
workflow sets it.
Afterwards: bump the dependency in swan-backend and deploy, then users must reconnect their connector to see the change (see the resource-cache note above).
npm setup, once
Trusted publishing is configured per package, so the package must exist first:
- publish
0.1.0manually once (npm publishfrom a clean clone) - on npmjs.com → the package → Settings → Trusted Publisher → GitHub
Actions, with organization
wompxyz, repositorymcp-ui, workflowpublish.yml, and no environment - from then on, tags publish by themselves
Known gaps
- PiP display mode untested.
- Mesh lighting is three directional/ambient lights, not an HDR environment — an env map means another cross-origin fetch and another CSP entry. PBR materials may read flatter than in the Womp editor.
- Mesh card is 900 KB. Almost all of it is three.js plus ~260 KB of zod/MCP-SDK from the bridge. A hand-rolled bridge (the handshake is ~5 messages) would cut that 260 KB from both cards; deferred because three.js dominates anyway.
- No corner
…menu from swan-frontend's tile. edit_imagedoes not exist in swan-backend, so no edit affordance.- The prose nudge for 3D conversion is not deterministic — the model may not always offer it.
