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

storeframe-mcp

v0.2.0

Published

The bundle-emitting MCP server over the @engine/@sync spine. Exposes render_strip, emit_bundle, read_look to agents (Claude Code / Cursor / CI) over Streamable HTTP, auth'd with personal Storeframe API tokens. Zero-custody: no code path accepts, stores, o

Readme

storeframe-mcp

A hosted-ready MCP server that turns app screenshots into styled 3D App Store mockups, emits a fastlane deliver-ready bundle, and reads your saved Storeframe look — for agents (Claude Code / Cursor / CI). It runs over the same @engine render spine the Studio web app and the mockup-mcp CLI use, driving headless Chromium (Playwright).

Ships as two tiers, one engine: a free npx storeframe-mcp local stdio server that renders on your own machine with no account (see Local (free tier) below), and a hosted server on Fly.io with an account, saved looks, and share links (see Hosted (Fly.io) below, or the public /connect page in the studio app).

Zero-custody

This server never touches a store-signing credential. There is no tool input, env var, or code path that accepts, stores, or forwards one. emit_bundle hands you a zip; you upload it with your own fastlane, signed in as yourself. The generated Deliverfile is screenshots-only and never submits for review. The only secrets this process holds are Supabase service credentials (to resolve your API token, read your saved look, and back share links) — nothing that could reach a store on your behalf.

Tools

| Tool | What it does | Key inputs | |------|--------------|------------| | render_strip | Render screenshots into mocked-up per-panel PNGs | screenshots, panelPresetId?, style? / look? / useSavedLook?, project?, version?, locale?, preview?, output? | | emit_bundle | render_strip (or pre-rendered panels) + a fastlane deliver zip (+ optional share link) | screenshots or panels, bundleId, locale?, locales? (multi-locale bundle), projectName?, share?, output? (+ the same styling params) | | save_project | Save the strip as an editable Studio project (structure + look only — does NOT render, so it never hits the render timeout); returns projectId + an openUrl | screenshots, project? (update in place; omit to create), projectName?, panelPresetId?, style? / look? / useSavedLook?, version?, locale?, sourceDir? | | read_look | Return a project's saved Storeframe look (styling only), with its version + pin state | project? (default: your most recently edited project) | | read_project | Return a project's FULL current state as an opaque ProjectFile — frame order, per-locale caption words, locale list, styling. Answers "what did the designer change?" (remember updatedAt, re-read later) | project? (default: your most recently edited project) | | render_project | Re-render a SAVED project with your OWN raw screenshots, matched to its shots by filename — the project supplies frame order, captions + styling, so the result reproduces the designer's strip | project?, screenshots (a FLAT list, each with its original filename as name), locale?, preview?, output? | | save_look | Persist a composed look on a project (styling only — bumps its content version) | look, project?, sourceName? | | describe_look | The styling field catalog + defaults, for authoring from scratch | (none) | | request_screenshot_upload | Mint signed upload slots for real screenshots | count (1–10), names?, locale? (a per-locale batch tag) |

  • panelPresetId — App Store size: r69 (default, 6.9″ iPhone 1290×2796), r65, r55, ipad13, ipad129. This enum is a hand-kept copy of mockup-engine/appstore.ts's PANEL_PRESETS (see storeframe-mcp/src/schemas.ts's PANEL_PRESET_IDS/PANEL_DIMENSIONS) — when Apple revises required sizes, add the preset in the engine first, then mirror it here.
  • style — the structured styling input (call describe_look for the full catalog): { shotLook?, background?, captions? }. captions is one entry PER PANEL in slot order; each entry is EITHER a single caption object OR an array of caption layers stacked on that panel (headline + subline + …). Each layer's text (and a single caption's subtitle) is per-RENDER input (never stored — a look carries styling only). Captions auto-layout above the device so they never collide with the phone. Wins over look/useSavedLook.
  • look — a Storeframe look JSON exactly as read_look returns it; feed it back in to restyle. Omit for default styling.
  • version — render a specific saved look version (implies the saved look). Without it, a project with a PINNED version renders the pin; otherwise the latest saved look.
  • emit_bundle output — a zip (README.md, fastlane/Deliverfile, fastlane/screenshots/<locale>/NN_storeframe.png) as zipBase64 or zipUrl/zipRef (see Output below), plus shareLink when share: true.
  • locale — an App Store locale code, e.g. de-DE (default en-US). Labels the render, picks which { "locales": … } screenshot variants render (see Per-locale screenshots), and, for emit_bundle, picks the fastlane/screenshots/<locale>/ folder. It does not select caption text — this server never stores or looks up Studio-typed copy (repo owns the words). Pass the right words for that locale yourself in style.captions[].text / subtitle.

Per-locale screenshots & multi-locale bundles

Any screenshots entry (and any emit_bundle panels entry) can carry per-locale variants instead of one image:

{ "locales": { "en-US": { "ref": "…en…" }, "de-DE": { "ref": "…de…" } } }
  • render_strip({ locale: "de-DE", … }) renders the de-DE variant of each such entry. A locale with no variant of its own falls back to the en-US variant (else the first declared) — so you can ship German captions over English screenshots today and upgrade the pixels later. Plain (non-locales) entries serve every locale. The response echoes the declared locales as screenshotLocales.
  • emit_bundle({ locales: ["en-US", "de-DE"], … }) emits ONE zip with a fastlane/screenshots/<locale>/ folder per listed locale — fastlane deliver uploads every locale in a single run. A single-entry list behaves exactly like locale; omitting locales is the unchanged single-locale bundle.
  • request_screenshot_upload({ count, names, locale }) tags a batch of upload slots with the locale it's for (echoed back — bookkeeping only; refs are locale-agnostic).

Recommended multi-locale flow — render per locale, then compose once. Caption text is per-render input, so this is also the only way to get per-locale captions into one bundle, and it keeps each call fast (see "Cheap preview, then compose" below):

  1. Per locale L: render_strip({ locale: L, output: "urls", style: { captions: [<L's words>] }, screenshots: […] }) → per-panel refs for L.
  2. One emit_bundle({ locales: […], bundleId, panels: [ { "locales": { "en-US": { "ref": "p1-en" }, "de-DE": { "ref": "p1-de" } } }, … ] }) — zips every locale without re-rendering.

The one-call form (emit_bundle with screenshots + locales) works too, but renders once per locale with the same captions for every locale, and multiplies the slow full-res render by the locale count.

The captions.<locale>.json convention

The canonical source of shipping caption copy is a file in your own repo, one per locale, keyed by panel ordinal (0-based, in strip order — not panel id, so the file stays stable across re-renders):

A panel's entry is EITHER a single caption ({ headline, subtitle? }) OR an array of layers (each { text, … }, stacked in order) — use the array when you want more than a headline + one subtitle, e.g. an eyebrow over a headline over a caption:

// captions.de-DE.json
{
  "0": { "headline": "Alles im Blick", "subtitle": "Deine Tage, übersichtlich." },
  "1": [ { "text": "NEU" }, { "text": "Schneller planen" } ]
}

To render a locale: read that file, map it onto style.captions[] in the same order (index i → panel i, null for a panel with no caption), and pass locale alongside so the render/bundle is labeled and routed consistently. A single caption maps to one object; a layer array maps straight through as an array:

{
  "locale": "de-DE",
  "style": { "captions": [
    { "text": "Alles im Blick", "subtitle": "Deine Tage, übersichtlich." },
    [ { "text": "NEU", "sizePt": 28 }, { "text": "Schneller planen" } ]
  ] }
}

There is no MCP tool to read Studio-typed caption text back — Studio's per-locale captions are for visual preview only; your repo's captions.<locale>.json is what actually ships.

Agents: don't inline large images

A real marketing screenshot is easily 1–2MB as a PNG — 30–40% bigger again as base64 — and that base64 string flows through your OWN tool-call arguments, in YOUR context window, before it ever reaches this server. Passing full-resolution screenshots inline can blow past your context budget (or get silently truncated by a file-read tool) long before rendering happens.

Upload out-of-band instead:

  1. Call request_screenshot_upload({ count: N }) — returns N { ref, uploadUrl } slots under your own private prefix, plus a copy-paste curl -T example.
  2. curl -T screenshot.png "<uploadUrl>" each screenshot directly (bytes never touch this conversation).
  3. Pass { "ref": "<ref>" } — not inline base64 — as that screenshot's entry in render_strip/emit_bundle's screenshots array.

screenshots entries accept these shapes: an inline base64 string (small payloads only — inline is capped and returns a clear "payload too large" error above ~8MB decoded, pointing you back at this flow), { ref } from step 1, { url } (an https URL this server fetches itself: image/png, no redirects, ~20MB cap), { path } (local stdio server only — see below), or { "locales": { "<locale>": <any of those> } } for per-locale variants (see Per-locale screenshots above).

Output works the same way in reverse: render_strip/emit_bundle take an output field — "inline" (base64), "urls" (uploaded under your own prefix, short-lived signed download URLs), or omit for auto (inline under ~200KB total, urls above). Use "urls" for full-resolution work so results never transit your context either.

Cheap preview, then compose: render_strip({ preview: true }) renders at ~25% resolution for fast, cheap styling iteration — small enough to always come back inline; don't ship it, re-render without preview (or call emit_bundle directly) once the look is right. Once you have full-resolution panel refs from a render_strip({ output: "urls" }) call, emit_bundle({ panels: [{ ref }, ...] }) packages them into a bundle WITHOUT rendering again — skip screenshots entirely in that call.

Orphaned uploads aren't auto-purged yet (a follow-up item) — treat refs as short-lived and single-use.

Prerequisites

  1. A Storeframe API token. Sign in at the Studio app → account menu → API tokens → create one. It looks like sfs_9Zq3Xr7Tp0Vn6Lk2Wd8Yb4Mf1Hc5Ju3Rs9Ea7Gt0Nz1 and is shown once — copy it then.
  2. Supabase service env (already in the repo's .env.local for local dev): SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY. The server loads ../.env.local automatically.
  3. Playwright's Chromium (installed with the package; run npx playwright install chromium once if it's missing).

Run it

cd storeframe-mcp
npm install
npm run dev            # boots on http://localhost:8788/mcp  (override with MCP_PORT)

Chromium boots lazily on the first render_strip / emit_bundle call, so startup is fast. GET /health is an unauthenticated liveness probe.

Connect Claude Code

claude mcp add --transport http storeframe http://localhost:8788/mcp \
  --header "Authorization: Bearer sfs_9Zq3Xr7Tp0Vn6Lk2Wd8Yb4Mf1Hc5Ju3Rs9Ea7Gt0Nz1"

Then ask the agent to, e.g., "render these two screenshots into a 6.9″ App Store strip and emit a fastlane bundle for com.acme.app." Every request must carry the Authorization: Bearer sfs_… header; a missing, unknown, or revoked token gets a 401 before any tool runs.

Other clients

  • Cursor / any MCP client — point it at the Streamable-HTTP URL http://localhost:8788/mcp with the same Authorization: Bearer sfs_… header.
  • Raw / CI — POST JSON-RPC to /mcp with the header; use the official MCP client or the inspector (npx @modelcontextprotocol/inspector) to explore interactively.

After you get a bundle

# decode zipBase64 from emit_bundle's result to a file, then:
unzip storeframe-appstore-upload.zip -d upload && cd upload
fastlane deliver        # signs in as YOU, previews, uploads screenshots to a draft — never submits

Hosted (Fly.io)

The server also runs hosted, 24/7-reachable (scale-to-zero when idle), at:

https://storeframe-mcp.fly.dev/mcp

Nothing to install — point any MCP client at that URL with your Authorization: Bearer sfs_… header, same as local dev, just swap the base URL:

claude mcp add --transport http storeframe https://storeframe-mcp.fly.dev/mcp \
  --header "Authorization: Bearer sfs_…"

The public /connect page in the studio app has the same instructions for Cursor/CI, plus a link to where to create a token — it's the one place an external agent user (not a repo collaborator) can find the connect story, since this repo is private.

Ops (this repo's maintainer only)

  • Deploy (from the repo root — the Dockerfile needs ../mockup-engine and ../api/_lib as build-context siblings):
    flyctl deploy . --config storeframe-mcp/fly.toml --remote-only --ha=false
  • Secrets (fly secrets set -a storeframe-mcp KEY=value): SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY — nothing Apple-related, ever, by construction.
  • Verify after any deploy — a green flyctl deploy isn't proof the server answers MCP calls:
    node verify-mcp.mjs https://storeframe-mcp.fly.dev/mcp <a-real-sfs_-token>
    Exercises tools/list, read_look, emit_bundle (asserts the zip unzips to README/Deliverfile/screenshots), and asserts no-token / bad-token both get 401.
  • Logs / status: flyctl logs -a storeframe-mcp, flyctl status -a storeframe-mcp.
  • The Dockerfile is pinned to mcr.microsoft.com/playwright:v1.61.1-noble — matching the playwright npm version in package.json. If you bump one, bump the other.
  • The container ships the harness as a Vite dev server (not a static build) — simplest, proven path; it needs the full mockup-engine source + GLB reachable at runtime, which is why the whole repo (minus .dockerignore'd dirs) is the build context.
  • VM size is shared-cpu-2x (2 vCPU / 2GB), not shared-cpu-1x (found live, Phase 10): Chromium's software WebGL render (no GPU on Fly's shared tier) is CPU-heavy enough that on a single shared vCPU it can starve the whole process — health checks stop responding, SIGTERM/SIGINT get ignored, and Fly force-kills the VM ("Virtual machine exited abruptly") mid-render. On 2 vCPUs the same 3-panel real-screenshot render completes in ~8s. Don't drop back to shared-cpu-1x to save cost — a render-triggering tool call is expected to occasionally saturate a whole core, and 1x has no second core to keep the process alive while that happens. Client callers (agents, verify-mcp.mjs) should still set a generous per-call timeout (≥180s) for render_strip/emit_bundle — even warm, cold-machine renders can take 60-110s before the CPU fix, and network/Supabase round trips add more on urls output.

Local (free tier) — npx storeframe-mcp

The SAME server also runs as a local stdio MCP, entirely on your own machine — no URL, no account, no Fly cost, and no render-timeout ceiling (the hosted server's one real limit — see "Ops" above). It's the same registerTools/render engine as the hosted server; only the transport and a few account-shaped tools differ.

claude mcp add storeframe -- npx -y storeframe-mcp

One-time setup: Chromium isn't bundled in the npm package (it's ~150MB) — Playwright downloads it on first run, or install it explicitly:

npx playwright install chromium

Screenshots are read straight off your disk — no upload dance. Pass a local file path instead of { ref }/{ url }:

{ "screenshots": [[{ "path": "/Users/you/screens/01_home.png" }]] }

({ "path": ... } only works over this LOCAL server — the hosted server rejects it, since reading an arbitrary server-side path there would be a local-file-inclusion hole.)

What's different locally, and why: every tool that needs an ACCOUNT to read or persist to — read_look / save_look / save_project / read_project / render_project / share links — has nothing to talk to in local mode by default, so they cleanly no-op with a "sign in" message. render_strip / emit_bundle are otherwise fully functional — you lose nothing on rendering itself, only on persistence, which is exactly the free/paid line:

| | Local (free, npx storeframe-mcp) | Hosted (Fly, sfs_… token) | |---|---|---| | Render compute | your machine ($0, no timeout) | our Fly machine | | Auth | none (the process runs as you) | account + API token | | Saved looks / editable projects / project read+re-render / share links | opt-in via a token (below) — share links still need a direct hosted connection | ✅ | | Screenshot input | local { path } off disk | request_screenshot_upload{ ref } |

Optional: bridge a local render into your hosted account

Pass a personal Storeframe API token (Studio → account menu → API tokens — the same sfs_… token the hosted server takes as a bearer) and save_project / read_look / save_look / read_project / render_project start working too — rendering still happens locally and for free; only the small saved-project JSON (structure + look, never screenshot bytes) goes to your account:

STOREFRAME_TOKEN=sfs_… claude mcp add storeframe -- npx -y storeframe-mcp
# or: npx storeframe-mcp --token sfs_…

The saved project remembers the exact on-disk folder your screenshots came from (not just their filenames), so re-opening it on the SAME machine can point right back at it. Share links aren't bridged yet — they'd need a hosted rendering step, which would defeat local rendering's whole point — connect directly to the hosted server (above) for those.

Layout

storeframe-mcp/
  src/
    app.ts          the Express app as a pure factory (OAuth AS router + /oauth/callback +
                    /mcp's per-request auth gate + stateless transport) — driveable in tests
                    with injected deps; server.ts calls it with the real Supabase-backed ones
    server.ts       wires app.ts's real deps (Supabase-backed auth/looks/oauth) + listens
    auth.ts         thin wrapper over the verifyApiToken substrate (covers both sfs_ tokens
                    and OAuth-minted tokens — both are api_tokens rows)
    oauthProvider.ts  the OAuth 2.1 Authorization Server (DCR + PKCE + token mint) the SDK's
                    mcpAuthRouter drives; delegates human login to Supabase Google sign-in
    oauthStore.ts   OAuth AS persistence (mcp_oauth_clients / mcp_oauth_auth_codes), raw
                    service-role PostgREST, RLS-on-no-policies like api_tokens/share_links
    supabaseLogin.ts  the Supabase Google-login leg (server-side PKCE, /oauth/callback)
    tools.ts        render_strip / emit_bundle / read_look / save_look / describe_look / request_screenshot_upload / save_project handlers + registrar
    schemas.ts      zod input schemas incl. the structured `style` (no store-credential field exists) + panel presets
    renderer.ts     StripRenderer — boots Vite (hosted) or the static harness (local) + Chromium once, exposes render(input)
    staticServer.ts  the tiny node:http static server for harness-dist/ (local mode's serve path)
    lookStore.ts    per-PROJECT looks + projects resolver (ownership enforced in code; D1 record/pinned_record pair)
    shareEmit.ts    emit_bundle's server-side share-link creation (reuses Phase-2 modules)
    uploads.ts      Phase 10 — upload slots, ref ownership check, output:'urls' storage
    screenshotInput.ts  Phase 10 — resolves screenshots[] (inline/ref/url/path) to base64
    env.ts          loads ../.env.local; MCP_PORT / STUDIO_ORIGIN / MCP_PUBLIC_URL
    local.ts        the LOCAL (free tier) npx entry point — stdio transport, no auth
    localDeps.ts     the local ToolDeps fork — static-serve renderer, no-op/bridged persistence
    hostedBridge.ts  local→hosted bridge (a real MCP client using your sfs_ token)
  harness/          the browser render page Chromium loads (render.js exposes window.renderStrip)
  vite.config.mjs   hosted (Vite dev server) harness config — mirrors mockup-mcp's headless-render setup
  vite.harness.config.mjs  the STATIC harness build (local mode's publish prerequisite) → harness-dist/
  build.mjs         esbuild bundle of src/local.ts (+ @engine/@api inlined) → dist/local.js
  Dockerfile        hosted image — Playwright's Chromium base + this repo's 3 npm installs
  fly.toml          Fly app config (scale-to-zero, /health check) — deploy from repo root
  verify-mcp.mjs    post-deploy smoke test — real MCP round-trip against a hosted URL
  verify-mcp-local.ts  local stdio smoke test (tsx src/local.ts, in-repo)
  static-parity.ts  pixel-diffs the static-serve render against the Vite-dev render (must be 0)
  pack-smoke.ts     THE publish gate — npm pack, install OUTSIDE the repo, drive a real render