shotops-mcp 0.5.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,419 @@
1
+ # shotops-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 ShotOps look — for agents
5
+ (ChatGPT / 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 shotops-mcp` **local stdio server** that
9
+ renders on your own machine with no account (see [Local (free tier)](#local-free-tier---npx-shotops-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?`, `clip?`, `style?` **+** `look?` (they compose) / `useSavedLook?`, `project?`, `version?`, `locale?`, `preview?`, `output?` |
28
+ | `emit_bundle` | `render_strip` (or pre-rendered `panels` — refs or local `{ path }`) + a `fastlane deliver` zip (+ optional share link) | `screenshots` or `panels`, `bundleId`, `clip?`, `locale?`, `locales?` (multi-locale bundle), `projectName?`, `share?`, `output?` (+ the same styling params) |
29
+ | `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?` |
30
+ | `read_look` | Return a project's saved ShotOps look (styling only), with its latest version, the held version, and the `versions` history | `project?` (default: your most recently edited project) |
31
+ | `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) |
32
+ | `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?` |
33
+ | `save_look` | Persist a composed look on a project (styling only — appends a new version) | `look`, `project?`, `sourceName?` |
34
+ | `hold_look` | Hold which saved version agents render by default (replaces pinning) — the version must exist (`read_look` `versions`) | `version`, `project?` |
35
+ | `release_look` | Clear the held version — agents Follow latest (render the newest saved look) | `project?` |
36
+ | `describe_look` | The styling field catalog + defaults, for authoring from scratch | *(none)* |
37
+ | `request_screenshot_upload` | Mint signed upload slots for real screenshots | `count` (1–10), `names?`, `locale?` (a per-locale batch tag) |
38
+ | `import_screenshot` | Import a PNG attached in ChatGPT and return a render-ready ref | `file`, `name?`, `locale?` |
39
+ | `delete_assets` | Permanently delete private uploaded screenshots, rendered panels, or generated bundles owned by the signed-in account | `refs` (1–50 ShotOps refs) |
40
+
41
+ - **`panelPresetId`** — App Store size: `r69` (default, 6.9″ iPhone 1290×2796), `r65`, `r55`,
42
+ `ipad13`, `ipad129`. This enum is a hand-kept copy of `mockup-engine/appstore.ts`'s
43
+ `PANEL_PRESETS` (see `shotops-mcp/src/schemas.ts`'s `PANEL_PRESET_IDS`/`PANEL_DIMENSIONS`)
44
+ — when Apple revises required sizes, add the preset in the engine first, then mirror it
45
+ here.
46
+ - **`style`** — the structured styling input (call `describe_look` for the full catalog):
47
+ `{ shotLook?, background?, captions? }`. `shotLook` is **one device for every phone**; `captions`
48
+ is one entry PER PANEL in slot order — each entry is EITHER a single caption object OR an **array
49
+ of caption layers** stacked on that panel (headline + subline + …). Each layer's `text` (and a
50
+ single caption's `subtitle`) is per-RENDER input (never stored — a look carries styling only).
51
+ Captions auto-layout above the device so they never collide with the phone.
52
+ - **`look`** — a ShotOps look JSON exactly as `read_look` returns it, but you can also
53
+ hand-author one: it gives each panel its **own** device (`shots[]`, tint/tilt/size per panel),
54
+ which `style.shotLook` can't. **Validated** — unknown keys are rejected (a typo won't silently
55
+ render wrong). Omit for default styling.
56
+ - **`style` + `look` compose** — every real App Store strip needs BOTH a different device per
57
+ panel AND captions, so pass them **together**: the `look` supplies the per-panel devices +
58
+ background, `style.captions` supplies the words, in one render. (Alone, `style` styles every
59
+ phone identically. If you pass `style.shotLook`/`style.background` *alongside* a `look`, the look
60
+ wins and the result carries a note.) Also composes with `useSavedLook`/`version`.
61
+ - **`clip`** — `"strip"` (default) renders one continuous canvas, so a wide/offset device can
62
+ straddle the seam into the next panel (swipe-through scenes). `"panel"` clips each phone to its
63
+ own panel so it can't bleed a foreign edge into the neighbouring App Store screenshot. A render
64
+ option, not part of the saved look.
65
+ - **`version`** — render a specific saved look version (implies the saved look). Without it,
66
+ a project with a PINNED version renders the pin; otherwise the latest saved look.
67
+ - **`emit_bundle` output** — a zip (`README.md`, `fastlane/Deliverfile`,
68
+ `fastlane/screenshots/<locale>/NN_shotops.png`) as `zipBase64` or `zipUrl`/`zipRef` (see
69
+ Output below), plus `shareLink` when `share: true`.
70
+ - **`locale`** — an App Store locale code, e.g. `de-DE` (default `en-US`). Labels the render,
71
+ picks which `{ "locales": … }` screenshot variants render (see
72
+ [Per-locale screenshots](#per-locale-screenshots--multi-locale-bundles)), and, for
73
+ `emit_bundle`, picks the `fastlane/screenshots/<locale>/` folder. **It does not
74
+ select caption text** — this server never stores or looks up Studio-typed copy (repo owns
75
+ the words). Pass the right words for that locale yourself in `style.captions[].text` /
76
+ `subtitle`.
77
+
78
+ ### Per-locale screenshots & multi-locale bundles
79
+
80
+ Any `screenshots` entry (and any `emit_bundle` `panels` entry) can carry **per-locale
81
+ variants** instead of one image:
82
+
83
+ ```json
84
+ { "locales": { "en-US": { "ref": "…en…" }, "de-DE": { "ref": "…de…" } } }
85
+ ```
86
+
87
+ - `render_strip({ locale: "de-DE", … })` renders the `de-DE` variant of each such entry. A
88
+ locale with **no variant of its own falls back** to the `en-US` variant (else the first
89
+ declared) — so you can ship German captions over English screenshots today and upgrade the
90
+ pixels later. Plain (non-`locales`) entries serve every locale. The response echoes the
91
+ declared locales as `screenshotLocales`.
92
+ - `emit_bundle({ locales: ["en-US", "de-DE"], … })` emits **ONE zip** with a
93
+ `fastlane/screenshots/<locale>/` folder per listed locale — `fastlane deliver` uploads every
94
+ locale in a single run. A single-entry list behaves exactly like `locale`; omitting `locales`
95
+ is the unchanged single-locale bundle.
96
+ - `request_screenshot_upload({ count, names, locale })` tags a batch of upload slots with the
97
+ locale it's for (echoed back — bookkeeping only; refs are locale-agnostic).
98
+
99
+ **Recommended multi-locale flow — render per locale, then compose once.** Caption text is
100
+ per-render input, so this is also the only way to get per-locale *captions* into one bundle,
101
+ and it keeps each call fast (see "Cheap preview, then compose" below):
102
+
103
+ 1. Per locale `L`: `render_strip({ locale: L, output: "urls", style: { captions: [<L's words>] },
104
+ screenshots: […] })` → per-panel refs for `L`.
105
+ 2. One `emit_bundle({ locales: […], bundleId, panels: [ { "locales": { "en-US": { "ref": "p1-en" },
106
+ "de-DE": { "ref": "p1-de" } } }, … ] })` — zips every locale **without re-rendering**.
107
+
108
+ The one-call form (`emit_bundle` with `screenshots` + `locales`) works too, but renders once
109
+ per locale with the *same* captions for every locale, and multiplies the slow full-res render
110
+ by the locale count.
111
+
112
+ ### The `captions.<locale>.json` convention
113
+
114
+ The canonical source of shipping caption copy is a file **in your own repo**, one per locale,
115
+ keyed by **panel ordinal** (0-based, in strip order — not panel id, so the file stays stable
116
+ across re-renders):
117
+
118
+ A panel's entry is EITHER a single caption (`{ headline, subtitle? }`) OR an **array of layers**
119
+ (each `{ text, … }`, stacked in order) — use the array when you want more than a headline + one
120
+ subtitle, e.g. an eyebrow over a headline over a caption:
121
+
122
+ ```json
123
+ // captions.de-DE.json
124
+ {
125
+ "0": { "headline": "Alles im Blick", "subtitle": "Deine Tage, übersichtlich." },
126
+ "1": [ { "text": "NEU" }, { "text": "Schneller planen" } ]
127
+ }
128
+ ```
129
+
130
+ To render a locale: read that file, map it onto `style.captions[]` in the same order (index
131
+ `i` → panel `i`, `null` for a panel with no caption), and pass `locale` alongside so the
132
+ render/bundle is labeled and routed consistently. A single caption maps to one object; a layer
133
+ array maps straight through as an array:
134
+
135
+ ```json
136
+ {
137
+ "locale": "de-DE",
138
+ "style": { "captions": [
139
+ { "text": "Alles im Blick", "subtitle": "Deine Tage, übersichtlich." },
140
+ [ { "text": "NEU", "sizePt": 28 }, { "text": "Schneller planen" } ]
141
+ ] }
142
+ }
143
+ ```
144
+
145
+ There is no MCP tool to read Studio-typed caption text back — Studio's per-locale captions
146
+ are for *visual preview* only; your repo's `captions.<locale>.json` is what actually ships.
147
+
148
+ ### Agents: don't inline large images
149
+
150
+ A real marketing screenshot is easily 1–2MB as a PNG — 30–40% bigger again as base64 — and
151
+ that base64 string flows through your OWN tool-call arguments, in YOUR context window, before
152
+ it ever reaches this server. Passing full-resolution screenshots inline can blow past your
153
+ context budget (or get silently truncated by a file-read tool) long before rendering happens.
154
+
155
+ **Upload out-of-band instead:**
156
+
157
+ In ChatGPT, attach the PNG and call `import_screenshot` once per file. ChatGPT supplies the
158
+ temporary file parameter; ShotOps downloads and stores it server-to-server, then returns
159
+ the same `{ ref }` used by the render tools. In other hosted MCP clients, use the signed-slot
160
+ flow below:
161
+
162
+ 1. Call `request_screenshot_upload({ count: N })` — returns `N` `{ ref, uploadUrl }` slots
163
+ under your own private prefix, plus a copy-paste `curl -T` example.
164
+ 2. `curl -T screenshot.png "<uploadUrl>"` each screenshot directly (bytes never touch this
165
+ conversation).
166
+ 3. Pass `{ "ref": "<ref>" }` — not inline base64 — as that screenshot's entry in
167
+ `render_strip`/`emit_bundle`'s `screenshots` array.
168
+
169
+ `screenshots` entries accept these shapes: an inline base64 string (small payloads only —
170
+ inline is capped and returns a clear "payload too large" error above ~8MB decoded, pointing
171
+ you back at this flow), `{ ref }` from step 1, `{ url }` (an https URL this server fetches
172
+ itself: image/png, no redirects, ~20MB cap), `{ path }` (local stdio server only — see below),
173
+ or `{ "locales": { "<locale>": <any of those> } }` for per-locale variants (see
174
+ [Per-locale screenshots](#per-locale-screenshots--multi-locale-bundles) above).
175
+
176
+ **Output** works the same way in reverse: `render_strip`/`emit_bundle` take an `output` field
177
+ — `"inline"` (base64), `"urls"` (uploaded under your own prefix, short-lived signed download
178
+ URLs), or omit for auto (inline under ~200KB total, `urls` above). Use `"urls"` for
179
+ full-resolution work so results never transit your context either.
180
+
181
+ **Cheap preview, then compose:** `render_strip({ preview: true })` renders at ~25% resolution
182
+ for fast, cheap styling iteration — small enough to always come back inline; don't ship it,
183
+ re-render without `preview` (or call `emit_bundle` directly) once the look is right. Once
184
+ you have full-resolution panel refs from a `render_strip({ output: "urls" })` call,
185
+ `emit_bundle({ panels: [{ ref }, ...] })` packages them into a bundle WITHOUT rendering again
186
+ — skip `screenshots` entirely in that call.
187
+
188
+ Private uploaded screenshots and generated files are retained until deleted. Treat refs as
189
+ short-lived and single-use, then call `delete_assets({ refs: [...] })` when the files are no
190
+ longer needed. Deleting a ref is irreversible and can make a saved project's referenced
191
+ screenshot unavailable; public privacy disclosures must accurately state this retention model.
192
+
193
+ ## Prerequisites
194
+
195
+ 1. **A ShotOps API token.** Sign in at the Studio app → account menu → **API tokens** →
196
+ create one. It looks like `shotops_9Zq3Xr7Tp0Vn6Lk2Wd8Yb4Mf1Hc5Ju3Rs9Ea7Gt0Nz1` and is
197
+ shown once — copy it then.
198
+ 2. **Supabase service env** (already in the repo's `.env.local` for local dev): `SUPABASE_URL`
199
+ and `SUPABASE_SERVICE_ROLE_KEY`. The server loads `../.env.local` automatically.
200
+ 3. Playwright's Chromium (installed with the package; run `npx playwright install chromium`
201
+ once if it's missing).
202
+
203
+ ## Run it
204
+
205
+ ```bash
206
+ cd shotops-mcp
207
+ npm install
208
+ npm run dev # boots on http://localhost:8788/mcp (override with MCP_PORT)
209
+ ```
210
+
211
+ Chromium boots lazily on the first `render_strip` / `emit_bundle` call, so startup is fast.
212
+ `GET /health` is an unauthenticated liveness probe.
213
+
214
+ ## Connect ChatGPT (Developer Mode)
215
+
216
+ Deploy the hosted server at a public HTTPS origin, enable Developer Mode in ChatGPT, and create
217
+ an app whose MCP server URL is `https://<your-host>/mcp`. Do not paste a personal `shotops_` token
218
+ into the app definition: ChatGPT discovers the protected-resource metadata from the endpoint and
219
+ runs the server's OAuth + PKCE browser sign-in flow. This is a tool-only app — no widget or UI
220
+ resource is required. User-attached PNGs enter through `import_screenshot`.
221
+
222
+ For public plugin submission, OpenAI supplies a domain-verification token after the MCP domain
223
+ is entered in the Platform dashboard. Set it on the hosted server as
224
+ `OPENAI_APPS_CHALLENGE_TOKEN`; the server then returns that exact value from
225
+ `/.well-known/openai-apps-challenge`. With no token configured, the route returns 404.
226
+ The server also exposes a synthetic, non-user PNG at
227
+ `/review-fixtures/sample-app-screen.png` so reviewers can reproduce attachment and URL-input
228
+ tests without private fixture data or third-party hosting.
229
+
230
+ ## Connect Claude Code
231
+
232
+ ```bash
233
+ claude mcp add --transport http shotops http://localhost:8788/mcp \
234
+ --header "Authorization: Bearer shotops_9Zq3Xr7Tp0Vn6Lk2Wd8Yb4Mf1Hc5Ju3Rs9Ea7Gt0Nz1"
235
+ ```
236
+
237
+ Then ask the agent to, e.g., *"render these two screenshots into a 6.9″ App Store strip and
238
+ emit a fastlane bundle for com.acme.app."* Every request must carry the
239
+ `Authorization: Bearer shotops_…` header; a missing, unknown, or revoked token gets a `401`
240
+ before any tool runs.
241
+
242
+ ### Other clients
243
+
244
+ - **Cursor / any MCP client** — point it at the Streamable-HTTP URL `http://localhost:8788/mcp`
245
+ with the same `Authorization: Bearer shotops_…` header.
246
+ - **Raw / CI** — POST JSON-RPC to `/mcp` with the header; use the official MCP client or the
247
+ inspector (`npx @modelcontextprotocol/inspector`) to explore interactively.
248
+
249
+ ## After you get a bundle
250
+
251
+ ```bash
252
+ # decode zipBase64 from emit_bundle's result to a file, then:
253
+ unzip shotops-appstore-upload.zip -d upload && cd upload
254
+ fastlane deliver # signs in as YOU, previews, uploads screenshots to a draft — never submits
255
+ ```
256
+
257
+ ## Hosted (Fly.io)
258
+
259
+ The server also runs hosted, 24/7-reachable (scale-to-zero when idle), at:
260
+
261
+ ```
262
+ https://shotops-mcp.fly.dev/mcp
263
+ ```
264
+
265
+ Nothing to install — point any MCP client at that URL with your `Authorization: Bearer
266
+ shotops_…` header, same as local dev, just swap the base URL:
267
+
268
+ ```bash
269
+ claude mcp add --transport http shotops https://shotops-mcp.fly.dev/mcp \
270
+ --header "Authorization: Bearer shotops_…"
271
+ ```
272
+
273
+ The public [`/connect`](https://storeframe-studio.vercel.app/connect) page in the studio
274
+ app has the same instructions for Cursor/CI, plus a link to where to create a token — it's
275
+ the one place an external agent user (not a repo collaborator) can find the connect story,
276
+ since this repo is private.
277
+
278
+ ### Ops (this repo's maintainer only)
279
+
280
+ - **Deploy** (from the **repo root** — the Dockerfile needs `../mockup-engine` and
281
+ `../api/_lib` as build-context siblings):
282
+ ```bash
283
+ flyctl deploy . --config shotops-mcp/fly.toml --remote-only --ha=false
284
+ ```
285
+ - **Secrets** (`fly secrets set -a shotops-mcp KEY=value`): `SUPABASE_URL`,
286
+ `SUPABASE_SERVICE_ROLE_KEY`, plus `OPENAI_APPS_CHALLENGE_TOKEN` while verifying the MCP
287
+ domain for public ChatGPT plugin submission — nothing Apple-related, ever, by construction.
288
+ - **Verify after any deploy** — a green `flyctl deploy` isn't proof the server answers MCP
289
+ calls:
290
+ ```bash
291
+ node verify-mcp.mjs https://shotops-mcp.fly.dev/mcp <a-real-shotops_-token>
292
+ ```
293
+ Exercises `tools/list`, `read_look`, `emit_bundle` (asserts the zip unzips to
294
+ README/Deliverfile/screenshots), and asserts no-token / bad-token both get `401`.
295
+ - **Logs / status:** `flyctl logs -a shotops-mcp`, `flyctl status -a shotops-mcp`.
296
+ - The Dockerfile is pinned to `mcr.microsoft.com/playwright:v1.61.1-noble` — matching the
297
+ `playwright` npm version in `package.json`. If you bump one, bump the other.
298
+ - The container builds the browser harness once and serves it through a loopback-only static
299
+ server at runtime. Vite is build/parity tooling only and is pruned from production
300
+ dependencies; the Docker build context still includes `mockup-engine` so the harness can
301
+ bundle the shared engine + GLB.
302
+ - **VM size is `shared-cpu-2x` (2 vCPU / 2GB), not `shared-cpu-1x`** (found live, Phase 10):
303
+ Chromium's software WebGL render (no GPU on Fly's shared tier) is CPU-heavy enough that on
304
+ a single shared vCPU it can starve the whole process — health checks stop responding,
305
+ SIGTERM/SIGINT get ignored, and Fly force-kills the VM ("Virtual machine exited abruptly")
306
+ mid-render. On 2 vCPUs the same 3-panel real-screenshot render completes in ~8s. Don't
307
+ drop back to `shared-cpu-1x` to save cost — a render-triggering tool call is expected to
308
+ occasionally saturate a whole core, and 1x has no second core to keep the process alive
309
+ while that happens. Client callers (agents, `verify-mcp.mjs`) should still set a generous
310
+ per-call timeout (≥180s) for `render_strip`/`emit_bundle` — even warm, cold-machine renders
311
+ can take 60-110s before the CPU fix, and network/Supabase round trips add more on `urls`
312
+ output.
313
+
314
+ ## Local (free tier) — `npx shotops-mcp`
315
+
316
+ The SAME server also runs as a **local stdio MCP**, entirely on your own machine — no URL, no
317
+ account, no Fly cost, and no render-timeout ceiling (the hosted server's one real limit — see
318
+ "Ops" above). It's the same `registerTools`/render engine as the hosted server; only the
319
+ transport and a few account-shaped tools differ.
320
+
321
+ ```bash
322
+ claude mcp add shotops -- npx -y shotops-mcp
323
+ ```
324
+
325
+ One-time setup: Chromium isn't bundled in the npm package (it's ~150MB) — Playwright downloads
326
+ it on first run, or install it explicitly:
327
+
328
+ ```bash
329
+ npx playwright install chromium
330
+ ```
331
+
332
+ **Screenshots are read straight off your disk** — no upload dance. Pass a local file path
333
+ instead of `{ ref }`/`{ url }`:
334
+
335
+ ```json
336
+ { "screenshots": [[{ "path": "/Users/you/screens/01_home.png" }]] }
337
+ ```
338
+
339
+ (`{ "path": ... }` only works over this LOCAL server — the hosted server rejects it, since
340
+ reading an arbitrary server-side path there would be a local-file-inclusion hole.)
341
+
342
+ **`emit_bundle` reads local panels off disk too** — the free tier has no account, so it can't
343
+ produce `output: "urls"` refs. After rendering, pass the on-disk PNGs straight to `emit_bundle`
344
+ (same local-only `{ path }` door as screenshots) to package a `fastlane deliver` zip with no upload:
345
+
346
+ ```json
347
+ { "bundleId": "com.acme.app", "panels": [{ "path": "/abs/panel-01.png" }, { "path": "/abs/panel-02.png" }] }
348
+ ```
349
+
350
+ **What's different locally, and why:** every tool that needs an ACCOUNT to read or persist to —
351
+ `read_look` / `save_look` / `save_project` / `read_project` / `render_project` / share links —
352
+ has nothing to talk to in local mode by default, so they cleanly no-op with a "sign in" message.
353
+ `render_strip` / `emit_bundle` are otherwise **fully functional** — you lose nothing on rendering
354
+ itself, only on persistence, which is exactly the free/paid line:
355
+
356
+ | | Local (free, `npx shotops-mcp`) | Hosted (Fly, `shotops_…` token) |
357
+ |---|---|---|
358
+ | Render compute | **your machine** ($0, no timeout) | our Fly machine |
359
+ | Auth | none (the process runs as you) | account + API token |
360
+ | Saved looks / editable projects / project read+re-render / share links | opt-in via a token (below) — share links still need a direct hosted connection | ✅ |
361
+ | Screenshot input | local `{ path }` off disk | `request_screenshot_upload` → `{ ref }` |
362
+
363
+ ### Optional: bridge a local render into your hosted account
364
+
365
+ Pass a personal ShotOps API token (Studio → account menu → **API tokens** — the same
366
+ `shotops_…` token the hosted server takes as a bearer) and `save_project` / `read_look` /
367
+ `save_look` / `read_project` / `render_project` start working too — **rendering still happens
368
+ locally and for free; only the small saved-project JSON (structure + look, never screenshot
369
+ bytes) goes to your account:**
370
+
371
+ ```bash
372
+ SHOTOPS_TOKEN=shotops_… claude mcp add shotops -- npx -y shotops-mcp
373
+ # or: npx shotops-mcp --token shotops_…
374
+ ```
375
+
376
+ The saved project remembers the exact on-disk folder your screenshots came from (not just their
377
+ filenames), so re-opening it on the SAME machine can point right back at it. Share links aren't
378
+ bridged yet — they'd need a hosted rendering step, which would defeat local rendering's whole
379
+ point — connect directly to the hosted server (above) for those.
380
+
381
+ ## Layout
382
+
383
+ ```
384
+ shotops-mcp/
385
+ src/
386
+ app.ts the Express app as a pure factory (OAuth AS router + /oauth/callback +
387
+ /mcp's per-request auth gate + stateless transport) — driveable in tests
388
+ with injected deps; server.ts calls it with the real Supabase-backed ones
389
+ server.ts wires app.ts's real deps (Supabase-backed auth/looks/oauth) + listens
390
+ auth.ts thin wrapper over the verifyApiToken substrate (covers both shotops_ tokens
391
+ and OAuth-minted tokens — both are api_tokens rows)
392
+ oauthProvider.ts the OAuth 2.1 Authorization Server (DCR + PKCE + token mint) the SDK's
393
+ mcpAuthRouter drives; delegates human login to Supabase Google sign-in
394
+ oauthStore.ts OAuth AS persistence (mcp_oauth_clients / mcp_oauth_auth_codes), raw
395
+ service-role PostgREST, RLS-on-no-policies like api_tokens/share_links
396
+ supabaseLogin.ts the Supabase Google-login leg (server-side PKCE, /oauth/callback)
397
+ tools.ts render/bundle/project/look/upload/import/delete handlers + registrar
398
+ schemas.ts zod input schemas incl. the structured `style` (no store-credential field exists) + panel presets
399
+ renderer.ts StripRenderer — boots Vite (hosted) or the static harness (local) + Chromium once, exposes render(input)
400
+ staticServer.ts the tiny node:http static server for harness-dist/ (local mode's serve path)
401
+ lookStore.ts per-PROJECT looks + projects resolver (ownership enforced in code; D1 record/pinned_record pair)
402
+ shareEmit.ts emit_bundle's server-side share-link creation (reuses Phase-2 modules)
403
+ uploads.ts Phase 10 — upload slots, ref ownership check, output:'urls' storage
404
+ screenshotInput.ts Phase 10 — resolves screenshots[] (inline/ref/url/path) to base64
405
+ env.ts loads ../.env.local; MCP_PORT / STUDIO_ORIGIN / MCP_PUBLIC_URL
406
+ local.ts the LOCAL (free tier) npx entry point — stdio transport, no auth
407
+ localDeps.ts the local ToolDeps fork — static-serve renderer, no-op/bridged persistence
408
+ hostedBridge.ts local→hosted bridge (a real MCP client using your shotops_ token)
409
+ harness/ the browser render page Chromium loads (render.js exposes window.renderStrip)
410
+ vite.config.mjs hosted (Vite dev server) harness config — mirrors mockup-mcp's headless-render setup
411
+ vite.harness.config.mjs the STATIC harness build (local mode's publish prerequisite) → harness-dist/
412
+ build.mjs esbuild bundle of src/local.ts (+ @engine/@api inlined) → dist/local.js
413
+ Dockerfile hosted image — Playwright's Chromium base + this repo's 3 npm installs
414
+ fly.toml Fly app config (scale-to-zero, /health check) — deploy from repo root
415
+ verify-mcp.mjs post-deploy smoke test — real MCP round-trip against a hosted URL
416
+ verify-mcp-local.ts local stdio smoke test (tsx src/local.ts, in-repo)
417
+ static-parity.ts pixel-diffs the static-serve render against the Vite-dev render (must be 0)
418
+ pack-smoke.ts THE publish gate — npm pack, install OUTSIDE the repo, drive a real render
419
+ ```