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 ofmockup-engine/appstore.ts'sPANEL_PRESETS(seestoreframe-mcp/src/schemas.ts'sPANEL_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 (calldescribe_lookfor the full catalog):{ shotLook?, background?, captions? }.captionsis 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'stext(and a single caption'ssubtitle) 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 overlook/useSavedLook.look— a Storeframe look JSON exactly asread_lookreturns 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_bundleoutput — a zip (README.md,fastlane/Deliverfile,fastlane/screenshots/<locale>/NN_storeframe.png) aszipBase64orzipUrl/zipRef(see Output below), plusshareLinkwhenshare: true.locale— an App Store locale code, e.g.de-DE(defaulten-US). Labels the render, picks which{ "locales": … }screenshot variants render (see Per-locale screenshots), and, foremit_bundle, picks thefastlane/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 instyle.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 thede-DEvariant of each such entry. A locale with no variant of its own falls back to theen-USvariant (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 asscreenshotLocales.emit_bundle({ locales: ["en-US", "de-DE"], … })emits ONE zip with afastlane/screenshots/<locale>/folder per listed locale —fastlane deliveruploads every locale in a single run. A single-entry list behaves exactly likelocale; omittinglocalesis 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):
- Per locale
L:render_strip({ locale: L, output: "urls", style: { captions: [<L's words>] }, screenshots: […] })→ per-panel refs forL. - 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:
- Call
request_screenshot_upload({ count: N })— returnsN{ ref, uploadUrl }slots under your own private prefix, plus a copy-pastecurl -Texample. curl -T screenshot.png "<uploadUrl>"each screenshot directly (bytes never touch this conversation).- Pass
{ "ref": "<ref>" }— not inline base64 — as that screenshot's entry inrender_strip/emit_bundle'sscreenshotsarray.
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
- A Storeframe API token. Sign in at the Studio app → account menu → API tokens →
create one. It looks like
sfs_9Zq3Xr7Tp0Vn6Lk2Wd8Yb4Mf1Hc5Ju3Rs9Ea7Gt0Nz1and is shown once — copy it then. - Supabase service env (already in the repo's
.env.localfor local dev):SUPABASE_URLandSUPABASE_SERVICE_ROLE_KEY. The server loads../.env.localautomatically. - Playwright's Chromium (installed with the package; run
npx playwright install chromiumonce 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/mcpwith the sameAuthorization: Bearer sfs_…header. - Raw / CI — POST JSON-RPC to
/mcpwith 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 submitsHosted (Fly.io)
The server also runs hosted, 24/7-reachable (scale-to-zero when idle), at:
https://storeframe-mcp.fly.dev/mcpNothing 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-engineand../api/_libas 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 deployisn't proof the server answers MCP calls:
Exercisesnode verify-mcp.mjs https://storeframe-mcp.fly.dev/mcp <a-real-sfs_-token>tools/list,read_look,emit_bundle(asserts the zip unzips to README/Deliverfile/screenshots), and asserts no-token / bad-token both get401. - 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 theplaywrightnpm version inpackage.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-enginesource + 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), notshared-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 toshared-cpu-1xto 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) forrender_strip/emit_bundle— even warm, cold-machine renders can take 60-110s before the CPU fix, and network/Supabase round trips add more onurlsoutput.
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-mcpOne-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 chromiumScreenshots 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