storeframe-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,318 @@
1
+ # storeframe-mcp
2
+
3
+ A hosted-ready **MCP server** that turns app screenshots into styled 3D App Store mockups,
4
+ emits a `fastlane deliver`-ready bundle, and reads your saved Storeframe look — for agents
5
+ (Claude Code / Cursor / CI). It runs over the same `@engine` render spine the Studio web app
6
+ and the `mockup-mcp` CLI use, driving headless Chromium (Playwright).
7
+
8
+ Ships as **two tiers, one engine**: a free `npx storeframe-mcp` **local stdio server** that
9
+ renders on your own machine with no account (see [Local (free tier)](#local-free-tier---npx-storeframe-mcp)
10
+ below), and a **hosted** server on Fly.io with an account, saved looks, and share links (see
11
+ [Hosted (Fly.io)](#hosted-flyio) below, or the public
12
+ [`/connect`](https://storeframe-studio.vercel.app/connect) page in the studio app).
13
+
14
+ ## Zero-custody
15
+
16
+ This server **never touches a store-signing credential.** There is no tool input, env var,
17
+ or code path that accepts, stores, or forwards one. `emit_bundle` hands you a zip; **you**
18
+ upload it with **your own** `fastlane`, signed in as yourself. The generated `Deliverfile`
19
+ is screenshots-only and **never submits for review**. The only secrets this process holds
20
+ are Supabase service credentials (to resolve your API token, read your saved look, and back
21
+ share links) — nothing that could reach a store on your behalf.
22
+
23
+ ## Tools
24
+
25
+ | Tool | What it does | Key inputs |
26
+ |------|--------------|------------|
27
+ | `render_strip` | Render screenshots into mocked-up per-panel PNGs | `screenshots`, `panelPresetId?`, `style?` / `look?` / `useSavedLook?`, `project?`, `version?`, `locale?`, `preview?`, `output?` |
28
+ | `emit_bundle` | `render_strip` (or pre-rendered `panels`) + a `fastlane deliver` zip (+ optional share link) | `screenshots` or `panels`, `bundleId`, `locale?`, `projectName?`, `share?`, `output?` (+ the same styling params) |
29
+ | `read_look` | Return a project's saved Storeframe look (styling only), with its version + pin state | `project?` (default: your most recently edited project) |
30
+ | `save_look` | Persist a composed look on a project (styling only — bumps its content version) | `look`, `project?`, `sourceName?` |
31
+ | `describe_look` | The styling field catalog + defaults, for authoring from scratch | *(none)* |
32
+ | `request_screenshot_upload` | Mint signed upload slots for real screenshots | `count` (1–10) |
33
+
34
+ - **`panelPresetId`** — App Store size: `r69` (default, 6.9″ iPhone 1290×2796), `r65`, `r55`,
35
+ `ipad13`, `ipad129`. This enum is a hand-kept copy of `mockup-engine/appstore.ts`'s
36
+ `PANEL_PRESETS` (see `storeframe-mcp/src/schemas.ts`'s `PANEL_PRESET_IDS`/`PANEL_DIMENSIONS`)
37
+ — when Apple revises required sizes, add the preset in the engine first, then mirror it
38
+ here.
39
+ - **`style`** — the structured styling input (call `describe_look` for the full catalog):
40
+ `{ shotLook?, background?, captions? }`. `captions` is one entry PER PANEL in slot order;
41
+ each entry's `text`/`subtitle` is per-RENDER input (never stored — a look carries styling
42
+ only). Wins over `look`/`useSavedLook`.
43
+ - **`look`** — a Storeframe look JSON exactly as `read_look` returns it; feed it back in to
44
+ restyle. Omit for default styling.
45
+ - **`version`** — render a specific saved look version (implies the saved look). Without it,
46
+ a project with a PINNED version renders the pin; otherwise the latest saved look.
47
+ - **`emit_bundle` output** — a zip (`README.md`, `fastlane/Deliverfile`,
48
+ `fastlane/screenshots/<locale>/NN_storeframe.png`) as `zipBase64` or `zipUrl`/`zipRef` (see
49
+ Output below), plus `shareLink` when `share: true`.
50
+ - **`locale`** — an App Store locale code, e.g. `de-DE` (default `en-US`). Labels the render
51
+ and, for `emit_bundle`, picks the `fastlane/screenshots/<locale>/` folder. **It does not
52
+ select caption text** — this server never stores or looks up Studio-typed copy (repo owns
53
+ the words). Pass the right words for that locale yourself in `style.captions[].text` /
54
+ `subtitle`.
55
+
56
+ ### The `captions.<locale>.json` convention
57
+
58
+ The canonical source of shipping caption copy is a file **in your own repo**, one per locale,
59
+ keyed by **panel ordinal** (0-based, in strip order — not panel id, so the file stays stable
60
+ across re-renders):
61
+
62
+ ```json
63
+ // captions.de-DE.json
64
+ {
65
+ "0": { "headline": "Alles im Blick", "subtitle": "Deine Tage, übersichtlich." },
66
+ "1": { "headline": "Schneller planen" }
67
+ }
68
+ ```
69
+
70
+ To render a locale: read that file, map it onto `style.captions[]` in the same order (index
71
+ `i` → panel `i`, `null` for a panel with no caption), and pass `locale` alongside so the
72
+ render/bundle is labeled and routed consistently:
73
+
74
+ ```json
75
+ {
76
+ "locale": "de-DE",
77
+ "style": { "captions": [{ "text": "Alles im Blick", "subtitle": "Deine Tage, übersichtlich." }, { "text": "Schneller planen" }] }
78
+ }
79
+ ```
80
+
81
+ There is no MCP tool to read Studio-typed caption text back — Studio's per-locale captions
82
+ are for *visual preview* only; your repo's `captions.<locale>.json` is what actually ships.
83
+
84
+ ### Agents: don't inline large images
85
+
86
+ A real marketing screenshot is easily 1–2MB as a PNG — 30–40% bigger again as base64 — and
87
+ that base64 string flows through your OWN tool-call arguments, in YOUR context window, before
88
+ it ever reaches this server. Passing full-resolution screenshots inline can blow past your
89
+ context budget (or get silently truncated by a file-read tool) long before rendering happens.
90
+
91
+ **Upload out-of-band instead:**
92
+
93
+ 1. Call `request_screenshot_upload({ count: N })` — returns `N` `{ ref, uploadUrl }` slots
94
+ under your own private prefix, plus a copy-paste `curl -T` example.
95
+ 2. `curl -T screenshot.png "<uploadUrl>"` each screenshot directly (bytes never touch this
96
+ conversation).
97
+ 3. Pass `{ "ref": "<ref>" }` — not inline base64 — as that screenshot's entry in
98
+ `render_strip`/`emit_bundle`'s `screenshots` array.
99
+
100
+ `screenshots` entries accept three shapes: an inline base64 string (small payloads only —
101
+ inline is capped and returns a clear "payload too large" error above ~8MB decoded, pointing
102
+ you back at this flow), `{ ref }` from step 1, or `{ url }` (an https URL this server fetches
103
+ itself: image/png, no redirects, ~20MB cap).
104
+
105
+ **Output** works the same way in reverse: `render_strip`/`emit_bundle` take an `output` field
106
+ — `"inline"` (base64), `"urls"` (uploaded under your own prefix, short-lived signed download
107
+ URLs), or omit for auto (inline under ~200KB total, `urls` above). Use `"urls"` for
108
+ full-resolution work so results never transit your context either.
109
+
110
+ **Cheap preview, then compose:** `render_strip({ preview: true })` renders at ~25% resolution
111
+ for fast, cheap styling iteration — small enough to always come back inline; don't ship it,
112
+ re-render without `preview` (or call `emit_bundle` directly) once the look is right. Once
113
+ you have full-resolution panel refs from a `render_strip({ output: "urls" })` call,
114
+ `emit_bundle({ panels: [{ ref }, ...] })` packages them into a bundle WITHOUT rendering again
115
+ — skip `screenshots` entirely in that call.
116
+
117
+ Orphaned uploads aren't auto-purged yet (a follow-up item) — treat refs as short-lived and
118
+ single-use.
119
+
120
+ ## Prerequisites
121
+
122
+ 1. **A Storeframe API token.** Sign in at the Studio app → account menu → **API tokens** →
123
+ create one. It looks like `sfs_9Zq3Xr7Tp0Vn6Lk2Wd8Yb4Mf1Hc5Ju3Rs9Ea7Gt0Nz1` and is
124
+ shown once — copy it then.
125
+ 2. **Supabase service env** (already in the repo's `.env.local` for local dev): `SUPABASE_URL`
126
+ and `SUPABASE_SERVICE_ROLE_KEY`. The server loads `../.env.local` automatically.
127
+ 3. Playwright's Chromium (installed with the package; run `npx playwright install chromium`
128
+ once if it's missing).
129
+
130
+ ## Run it
131
+
132
+ ```bash
133
+ cd storeframe-mcp
134
+ npm install
135
+ npm run dev # boots on http://localhost:8788/mcp (override with MCP_PORT)
136
+ ```
137
+
138
+ Chromium boots lazily on the first `render_strip` / `emit_bundle` call, so startup is fast.
139
+ `GET /health` is an unauthenticated liveness probe.
140
+
141
+ ## Connect Claude Code
142
+
143
+ ```bash
144
+ claude mcp add --transport http storeframe http://localhost:8788/mcp \
145
+ --header "Authorization: Bearer sfs_9Zq3Xr7Tp0Vn6Lk2Wd8Yb4Mf1Hc5Ju3Rs9Ea7Gt0Nz1"
146
+ ```
147
+
148
+ Then ask the agent to, e.g., *"render these two screenshots into a 6.9″ App Store strip and
149
+ emit a fastlane bundle for com.acme.app."* Every request must carry the
150
+ `Authorization: Bearer sfs_…` header; a missing, unknown, or revoked token gets a `401`
151
+ before any tool runs.
152
+
153
+ ### Other clients
154
+
155
+ - **Cursor / any MCP client** — point it at the Streamable-HTTP URL `http://localhost:8788/mcp`
156
+ with the same `Authorization: Bearer sfs_…` header.
157
+ - **Raw / CI** — POST JSON-RPC to `/mcp` with the header; use the official MCP client or the
158
+ inspector (`npx @modelcontextprotocol/inspector`) to explore interactively.
159
+
160
+ ## After you get a bundle
161
+
162
+ ```bash
163
+ # decode zipBase64 from emit_bundle's result to a file, then:
164
+ unzip storeframe-appstore-upload.zip -d upload && cd upload
165
+ fastlane deliver # signs in as YOU, previews, uploads screenshots to a draft — never submits
166
+ ```
167
+
168
+ ## Hosted (Fly.io)
169
+
170
+ The server also runs hosted, 24/7-reachable (scale-to-zero when idle), at:
171
+
172
+ ```
173
+ https://storeframe-mcp.fly.dev/mcp
174
+ ```
175
+
176
+ Nothing to install — point any MCP client at that URL with your `Authorization: Bearer
177
+ sfs_…` header, same as local dev, just swap the base URL:
178
+
179
+ ```bash
180
+ claude mcp add --transport http storeframe https://storeframe-mcp.fly.dev/mcp \
181
+ --header "Authorization: Bearer sfs_…"
182
+ ```
183
+
184
+ The public [`/connect`](https://storeframe-studio.vercel.app/connect) page in the studio
185
+ app has the same instructions for Cursor/CI, plus a link to where to create a token — it's
186
+ the one place an external agent user (not a repo collaborator) can find the connect story,
187
+ since this repo is private.
188
+
189
+ ### Ops (this repo's maintainer only)
190
+
191
+ - **Deploy** (from the **repo root** — the Dockerfile needs `../mockup-engine` and
192
+ `../api/_lib` as build-context siblings):
193
+ ```bash
194
+ flyctl deploy . --config storeframe-mcp/fly.toml --remote-only --ha=false
195
+ ```
196
+ - **Secrets** (`fly secrets set -a storeframe-mcp KEY=value`): `SUPABASE_URL`,
197
+ `SUPABASE_SERVICE_ROLE_KEY` — nothing Apple-related, ever, by construction.
198
+ - **Verify after any deploy** — a green `flyctl deploy` isn't proof the server answers MCP
199
+ calls:
200
+ ```bash
201
+ node verify-mcp.mjs https://storeframe-mcp.fly.dev/mcp <a-real-sfs_-token>
202
+ ```
203
+ Exercises `tools/list`, `read_look`, `emit_bundle` (asserts the zip unzips to
204
+ README/Deliverfile/screenshots), and asserts no-token / bad-token both get `401`.
205
+ - **Logs / status:** `flyctl logs -a storeframe-mcp`, `flyctl status -a storeframe-mcp`.
206
+ - The Dockerfile is pinned to `mcr.microsoft.com/playwright:v1.61.1-noble` — matching the
207
+ `playwright` npm version in `package.json`. If you bump one, bump the other.
208
+ - The container ships the harness as a Vite **dev** server (not a static build) — simplest,
209
+ proven path; it needs the full `mockup-engine` source + GLB reachable at runtime, which is
210
+ why the whole repo (minus `.dockerignore`'d dirs) is the build context.
211
+ - **VM size is `shared-cpu-2x` (2 vCPU / 2GB), not `shared-cpu-1x`** (found live, Phase 10):
212
+ Chromium's software WebGL render (no GPU on Fly's shared tier) is CPU-heavy enough that on
213
+ a single shared vCPU it can starve the whole process — health checks stop responding,
214
+ SIGTERM/SIGINT get ignored, and Fly force-kills the VM ("Virtual machine exited abruptly")
215
+ mid-render. On 2 vCPUs the same 3-panel real-screenshot render completes in ~8s. Don't
216
+ drop back to `shared-cpu-1x` to save cost — a render-triggering tool call is expected to
217
+ occasionally saturate a whole core, and 1x has no second core to keep the process alive
218
+ while that happens. Client callers (agents, `verify-mcp.mjs`) should still set a generous
219
+ per-call timeout (≥180s) for `render_strip`/`emit_bundle` — even warm, cold-machine renders
220
+ can take 60-110s before the CPU fix, and network/Supabase round trips add more on `urls`
221
+ output.
222
+
223
+ ## Local (free tier) — `npx storeframe-mcp`
224
+
225
+ The SAME server also runs as a **local stdio MCP**, entirely on your own machine — no URL, no
226
+ account, no Fly cost, and no render-timeout ceiling (the hosted server's one real limit — see
227
+ "Ops" above). It's the same `registerTools`/render engine as the hosted server; only the
228
+ transport and a few account-shaped tools differ.
229
+
230
+ ```bash
231
+ claude mcp add storeframe -- npx -y storeframe-mcp
232
+ ```
233
+
234
+ One-time setup: Chromium isn't bundled in the npm package (it's ~150MB) — Playwright downloads
235
+ it on first run, or install it explicitly:
236
+
237
+ ```bash
238
+ npx playwright install chromium
239
+ ```
240
+
241
+ **Screenshots are read straight off your disk** — no upload dance. Pass a local file path
242
+ instead of `{ ref }`/`{ url }`:
243
+
244
+ ```json
245
+ { "screenshots": [[{ "path": "/Users/you/screens/01_home.png" }]] }
246
+ ```
247
+
248
+ (`{ "path": ... }` only works over this LOCAL server — the hosted server rejects it, since
249
+ reading an arbitrary server-side path there would be a local-file-inclusion hole.)
250
+
251
+ **What's different locally, and why:** `read_look` / `save_look` / `save_project` / share links
252
+ need an ACCOUNT to persist to, which local mode doesn't have by default — they cleanly no-op
253
+ with a "sign in" message. `render_strip` / `emit_bundle` are otherwise **fully functional** —
254
+ you lose nothing on rendering itself, only on persistence, which is exactly the free/paid line:
255
+
256
+ | | Local (free, `npx storeframe-mcp`) | Hosted (Fly, `sfs_…` token) |
257
+ |---|---|---|
258
+ | Render compute | **your machine** ($0, no timeout) | our Fly machine |
259
+ | Auth | none (the process runs as you) | account + API token |
260
+ | Saved looks / editable projects / share links | opt-in via a token (below) — share links still need a direct hosted connection | ✅ |
261
+ | Screenshot input | local `{ path }` off disk | `request_screenshot_upload` → `{ ref }` |
262
+
263
+ ### Optional: bridge a local render into your hosted account
264
+
265
+ Pass a personal Storeframe API token (Studio → account menu → **API tokens** — the same
266
+ `sfs_…` token the hosted server takes as a bearer) and `save_project` / `read_look` /
267
+ `save_look` start working too — **rendering still happens locally and for free; only the small
268
+ saved-project JSON (structure + look, never screenshot bytes) goes to your account:**
269
+
270
+ ```bash
271
+ STOREFRAME_TOKEN=sfs_… claude mcp add storeframe -- npx -y storeframe-mcp
272
+ # or: npx storeframe-mcp --token sfs_…
273
+ ```
274
+
275
+ The saved project remembers the exact on-disk folder your screenshots came from (not just their
276
+ filenames), so re-opening it on the SAME machine can point right back at it. Share links aren't
277
+ bridged yet — they'd need a hosted rendering step, which would defeat local rendering's whole
278
+ point — connect directly to the hosted server (above) for those.
279
+
280
+ ## Layout
281
+
282
+ ```
283
+ storeframe-mcp/
284
+ src/
285
+ app.ts the Express app as a pure factory (OAuth AS router + /oauth/callback +
286
+ /mcp's per-request auth gate + stateless transport) — driveable in tests
287
+ with injected deps; server.ts calls it with the real Supabase-backed ones
288
+ server.ts wires app.ts's real deps (Supabase-backed auth/looks/oauth) + listens
289
+ auth.ts thin wrapper over the verifyApiToken substrate (covers both sfs_ tokens
290
+ and OAuth-minted tokens — both are api_tokens rows)
291
+ oauthProvider.ts the OAuth 2.1 Authorization Server (DCR + PKCE + token mint) the SDK's
292
+ mcpAuthRouter drives; delegates human login to Supabase Google sign-in
293
+ oauthStore.ts OAuth AS persistence (mcp_oauth_clients / mcp_oauth_auth_codes), raw
294
+ service-role PostgREST, RLS-on-no-policies like api_tokens/share_links
295
+ supabaseLogin.ts the Supabase Google-login leg (server-side PKCE, /oauth/callback)
296
+ tools.ts render_strip / emit_bundle / read_look / save_look / describe_look / request_screenshot_upload / save_project handlers + registrar
297
+ schemas.ts zod input schemas incl. the structured `style` (no store-credential field exists) + panel presets
298
+ renderer.ts StripRenderer — boots Vite (hosted) or the static harness (local) + Chromium once, exposes render(input)
299
+ staticServer.ts the tiny node:http static server for harness-dist/ (local mode's serve path)
300
+ lookStore.ts per-PROJECT looks + projects resolver (ownership enforced in code; D1 record/pinned_record pair)
301
+ shareEmit.ts emit_bundle's server-side share-link creation (reuses Phase-2 modules)
302
+ uploads.ts Phase 10 — upload slots, ref ownership check, output:'urls' storage
303
+ screenshotInput.ts Phase 10 — resolves screenshots[] (inline/ref/url/path) to base64
304
+ env.ts loads ../.env.local; MCP_PORT / STUDIO_ORIGIN / MCP_PUBLIC_URL
305
+ local.ts the LOCAL (free tier) npx entry point — stdio transport, no auth
306
+ localDeps.ts the local ToolDeps fork — static-serve renderer, no-op/bridged persistence
307
+ hostedBridge.ts local→hosted bridge (a real MCP client using your sfs_ token)
308
+ harness/ the browser render page Chromium loads (render.js exposes window.renderStrip)
309
+ vite.config.mjs hosted (Vite dev server) harness config — mirrors mockup-mcp's headless-render setup
310
+ vite.harness.config.mjs the STATIC harness build (local mode's publish prerequisite) → harness-dist/
311
+ build.mjs esbuild bundle of src/local.ts (+ @engine/@api inlined) → dist/local.js
312
+ Dockerfile hosted image — Playwright's Chromium base + this repo's 3 npm installs
313
+ fly.toml Fly app config (scale-to-zero, /health check) — deploy from repo root
314
+ verify-mcp.mjs post-deploy smoke test — real MCP round-trip against a hosted URL
315
+ verify-mcp-local.ts local stdio smoke test (tsx src/local.ts, in-repo)
316
+ static-parity.ts pixel-diffs the static-serve render against the Vite-dev render (must be 0)
317
+ pack-smoke.ts THE publish gate — npm pack, install OUTSIDE the repo, drive a real render
318
+ ```