npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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.

  • durationSeconds is the provider's own stats.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: for generate_image it comes off the result in hand rather than the DB row, since Operation.ext.stats is written after the history resolves.
  • It is one of two lines, not one. #meta is what produced the result; #status is 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's color-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 into src/print/pantone-coated.ts (28 KB) by scripts/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_print and opens the project. The backend refuses hollow: true outright; the card never sends it. The whole section is driven by the quote's hollow row, 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 materials array 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, where add_to_cart and prepare_print read it. This is the load-bearing half. Colour and finish changes do not re-quote, so estimate_print could not have been the sync point.
  • calls updateModelContext via pushModelContext (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):

  • connectDomains must name the API origin, and the origin differs per environment — so bake-html.mjs bakes {} for this card and swan-backend completes the CSP with its own origin at read time. The dev stub does the same with http://localhost:3001.
  • The backend answers with open CORS on /mcp/v1/uploads because 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.html

Two 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 connector

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

The 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:

  1. publish 0.1.0 manually once (npm publish from a clean clone)
  2. on npmjs.com → the package → Settings → Trusted Publisher → GitHub Actions, with organization wompxyz, repository mcp-ui, workflow publish.yml, and no environment
  3. 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_image does 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.