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

shotops-mcp

v0.9.9

Published

The bundle-emitting MCP server over the @engine/@sync spine. Exposes ShotOps tools to ChatGPT, Claude Code, Cursor, and CI over Streamable HTTP with OAuth or personal API tokens. Hosted and MCP tool paths never accept, store, or forward a store-signing cr

Readme

shotops-mcp

A hosted-ready MCP server that makes the marketing screenshots an app store listing needs. Give it the raw screen captures from an app and it composites each one into a styled 3D device mockup, lays them out as a screenshot strip, sets your headlines over them, and hands back the panels as PNGs plus a fastlane deliver-ready bundle. Captions are held per locale, so one strip ships in every language you list in. It renders at App Store panel sizes and bundles for fastlane deliver, so a Google Play listing reuses the designs rather than the exact files. Designs save as reusable looks and editable projects, so the next release matches the last one.

Built for agents (ChatGPT / 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 doors, one engine: an npx shotops-mcp local stdio server that renders on your own machine, no account needed to preview (see Local below), and a hosted server on Vercel with an account, saved looks, and share links (see Hosted below, or the public MCP docs).

Preview free, export store-ready with Pro

Composing and previewing a strip is free through either door — every device, caption and locale, no watermark, and locally without an account at all. Store-ready output needs an active trial or Pro: a full-resolution render_strip/render_project, and every emit_bundle form, including one that only re-zips panels you already rendered. That holds wherever the render happens; local compute being free is not the same as local output being free.

The two limits are independent and neither substitutes for the other:

  • Entitlement is permission to create store-ready files. One server-owned decision answers it for Studio, hosted and local alike, so no door can be talked into a different answer. Free and anonymous callers are refused before a cloud credit is reserved, a screenshot is read or a file is written — a refused call costs nothing and leaves nothing behind.
  • Cloud credits price compute ShotOps supplies: 1 per hosted preview panel, 4 per hosted full-resolution panel, plus AI. Rendering on your own machine spends none, on any plan. More cloud credits never unlock store-ready output on Free.

Free accounts get 80 cloud credits a month and 2 synced projects. Every new account starts with a 7-day Pro trial; after that Pro is €9/month or €90/year. Without any account, the hosted server allows 3 preview strips per rolling 30 days, at most 5 panels each. Call account_status to see exactly where this connection stands — it spends no cloud credits and none of that anonymous allowance.

Zero-custody

The hosted server and every MCP tool never touch a store-signing credential. 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 one explicit exception is the local release setup CLI below. It validates an existing .p8 on your machine directly with Fastlane and Apple, then stores only its path and public identifiers. The key is never copied, printed, or sent to ShotOps, and no release tool is installed or upgraded.

Tools

| Tool | What it does | Key inputs | |------|--------------|------------| | render_strip | Preferred focused render: turn raw screenshots into mocked-up per-panel PNGs without creating or updating a project | screenshots, panelPresetId?, clip?, style? + look? (they compose) / useSavedLook?, project?, version?, locale?, preview?, output? | | emit_bundle | render_strip (or pre-rendered panels — refs or local { path }, or a saved project's own screenshots) + a fastlane deliver zip (+ optional share link) | screenshots or panels or project (bundles what that project holds), bundleId, clip?, outputs? (target devices — one bundle for several sizes; omit and a project's saved outputs are used), 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); always returns an openUrl. Authenticated saves also return projectId; unsigned local saves return a seven-day pending claim the user owns after opening the link and signing in | screenshots, project? (update in place only when explicitly targeted and authenticated; omit to create a new project or pending claim), projectName?, appListing? (an App Store URL, numeric app ID or bundle ID — the project takes the real app's name, bundle ID, languages and devices), outputs?, panelPresetId?, style? / look? / useSavedLook?, version?, locale?, sourceDir? | | read_look | Return a project's saved ShotOps look (styling only), with its latest version, the held version, the versions history, and the layout template it is composed on (derived from its values, never stored) | 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 — plus the derived layout and a screenshots.cells report for every shot × target-family × locale. Each cell says whether pixels resolve, where they resolved from, and which axes inherited. linkedApp names the App Store app the project is linked to, when it has one | project? (default: your most recently edited project) | | search_app_listing | Find the user's app on the App Store so a save can carry its real identity: each result carries the app's name, seller, icon, bundle ID and numeric app ID, plus the App Store Connect languages and iPhone devices ShotOps would apply. Free, renders nothing, writes nothing; needs an account. Finding a public listing proves nothing about who owns the app | term, country? (two-letter storefront, default us) | | render_project | Render a SAVED project. By default it renders the screenshots the project already holds — pass none at all, no upload needed — into every supported device the project targets. Pixels resolve independently for each device family + locale through the shared manifest; genuine fallback is reported in shots[].inherited and note | project?, screenshots? (optional — omit to use what the project holds; else a FLAT list matched by original name, with optional variant: { family?, locale? } per cell), outputs? (override the project's saved devices for this call), locale?, preview?, output? | | refine_project | Run one bounded Agent turn against a saved project. A supported request commits once and returns the actual diff; ambiguity, missing input, unsupported intent, conflict, and failure return a typed no-write outcome | project?, instruction, focus? | | save_look | Persist a composed look on a project (styling only — appends a new version) | look, project?, sourceName? | | hold_look | Hold which saved version agents render by default (replaces pinning) — the version must exist (read_look versions) | version, project? | | release_look | Clear the held version — agents Follow latest (render the newest saved look) | project? | | describe_look | Five named design presets, three named layout templates, the user-intake script, and the full styling field catalog + defaults | (none) | | request_screenshot_upload | Mint signed upload slots for real screenshots; every returned slot carries its semantic screenshot variant | count (1–10), names?, family?, locale? | | import_screenshot | Get screenshots INTO ShotOps — several per call, each from one named source ({ url }, { file }, or local-stdio { path }), returning a render-ready ref per entry in input order | screenshots (1–10 source entries), file? (the ChatGPT top-level attachment), name?, locale? | | account_status | What THIS connection may do, for free: signed-in account, plan, trial, remaining ShotOps cloud credits, what a preview costs here, and whether store-ready output (full-resolution renders, any emit_bundle) is allowed — with a machine-readable nextStep. Spends no cloud credits, uses none of the free anonymous preview allowance, uploads and writes nothing | (none) | | production_operation | Reconnect to one durable paid Hosted render or rendering bundle, report persisted progress, request cooperative cancellation, or mint fresh result download grants without rerendering or charging again | action (get, cancel, or result), operationId | | delete_assets | Permanently delete private uploaded screenshots, rendered panels, or generated bundles owned by the signed-in account | refs (1–50 ShotOps refs) |

  • panelPresetId — App Store size: r69 (default, 6.9″ iPhone 1320×2868), r65, r55. iPhone sizes only — the iPad presets are deliberately not offered, because no iPad device model exists yet and rendering one would put an iPhone body in an iPad-shaped canvas. The accepted set is RENDERABLE_PANEL_PRESET_IDS in shotops-mcp/src/render/panelCatalog.ts, a subset of the hand-kept PANEL_PRESET_IDS copy of mockup-engine/appstore.ts's PANEL_PRESETS — when Apple revises required sizes, add the preset in the engine first, then mirror it here.

  • style — the structured styling input. On both the hosted MCP and the local npx shotops-mcp stdio server, call describe_look for the authoritative complete field catalog and defaults: { layout?, shotLook?, background?, captions? }. shotLook is one device for every phone; 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 in a band at the top of the panel, and the device does NOT move to make room.

  • style.layout — the panel composition in one word: "standard", "bleed", "top-bleed", "lean-right", "lean-left", "swipe" or "corner-bleed". It settles the headline's reserved region, device placement, camera pose, and roll together. standard (the shipped default) puts the whole device under a 2-line region — about 36 characters — and crops nothing; bleed reserves 4 lines (~72 characters) and runs the device 10% off the bottom edge. The last four run it off a SIDE edge instead, into the neighbouring screenshot, so the set reads as one continuous swipe — pick one for every panel or none, and never mirror lean-right against lean-left, which puts two devices into the same seam. The region is reserved, not fitted: it is held at full size whether or not the headline fills it, which is what makes every panel in a swiped set land on the same line. Precedence — the template expands first, then any explicit shotLook composition field or caption layout field you also pass overrides it ({ layout: "bleed", shotLook: { vOffset: "45" } } is "bleed, but a bit lower"). A pure macro: nothing stores the template id, so save_project persists the expanded values and read_look returns them unchanged. The id is not lost, though — it is DERIVED back: read_project and read_look both return a layout report read out of the values themselves, so it can never go stale the way a stored id would. When a template was nudged, it reports the near miss instead of just failing to match:

    { "layout": "bleed" }                                    // every panel agrees
    { "layout": null, "closestLayout": "bleed",              // on Bleed, with one adjustment
      "differs": { "vOffset": { "is": "45", "template": "38" } } }
    { "layout": null, "closestLayout": null }                // nothing close

    It also answers per panel (layout.panels["panel-1"]), because a strip may mix compositions and a multi-device panel whose devices were dragged apart is honestly on no template at all. If you set placement by hand with no layout, the response note names the template that would have produced it — templates are the main road, raw fields are how you nudge one.

  • look — a ShotOps look JSON exactly as read_look returns it, but you can also hand-author one: it gives each screenshot/device its own styling through shots[], including two differently coloured devices in one panel. shots[] is flattened in panel order; repeat the same panelId for devices sharing a panel. This is what style.shotLook can't do. Validated — unknown keys are rejected (a typo won't silently render wrong). Omit for default styling.

  • style + look compose — every real App Store strip needs BOTH a different device per shot AND captions, so pass them together: the look supplies the per-device styling + background, style.captions supplies the words, in one render. (Alone, style styles every phone identically. If you pass style.shotLook/style.background alongside a look, the look wins and the result carries a note.) Also composes with useSavedLook/version.

  • Clipping — set look.shots[].look.clipToFrame: true to clip one device's complete composite (body, screen, shadow, and reflection) to its own panel; omit it or use false for overflow. Pass top-level clip: "panel" to clip every device, or clip: "strip" to force continuous overflow. Call describe_look for the complete Look contract on either MCP tier.

  • 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_shotops.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, family?, locale? }) returns each slot as { ref, uploadUrl, name?, variant }. For render_project, pass that variant through beside the ref: it is the device-family + locale cell the screenshot overrides, not a bookkeeping tag. Each name must exactly match the saved shot's original filename (frameName); the filename chooses the shot while variant chooses that shot's device-family + locale cell.

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:

import_screenshot moves them all in one call. Each entry of screenshots names exactly one source, and you get one result per entry, in the order you sent them:

{ "screenshots": [
  { "url": "https://example.com/01_home.png", "name": "01_home.png" },  // this server fetches it
  { "path": "/abs/02_stats.png" },                                       // local stdio server ONLY
  { "file": { "download_url": "…", "file_id": "…" } }                    // a ChatGPT attachment
] }

Two sources in one entry is rejected (split them into two entries). A failed entry reports its own error and never cancels the others — re-import just that one. In ChatGPT the attachment arrives as the top-level file parameter instead of an array entry, because the Apps SDK cannot fill a file parameter nested inside an array; you can pass both in the same call.

{ url } and { file } need the hosted server (it stores the fetched screenshot as PNG under your account); { path } needs the local stdio server (the hosted server never reads a caller-supplied filesystem path — that would be an LFI hole). Each refusal names the door that IS open on your tier. When the screenshots are only on the user's machine and you are on the hosted server, there is no URL to give, so use the signed-slot flow below:

  1. Call request_screenshot_upload({ count: N, names?, family?, locale? }) — returns N { ref, uploadUrl, name?, variant } 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. For render_project, also pass the slot's name and variant; name must exactly match the saved shot's original filename, and legacy entries without a variant remain the base/fallback cell.

screenshots entries accept these shapes: an inline base64 string (small payloads only — inline is capped and returns a clear "payload too large" error above 3MB decoded, pointing you back at this flow), { ref } from step 1, { url } (an https URL this server fetches itself: PNG or JPEG, converted to PNG when needed, 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).

Every raw PNG is automatically palette-optimized before rendering (TinyPNG-style, locally inside ShotOps): dimensions and transparency are preserved, and an already-smaller PNG is left byte-for-byte unchanged. Fetched JPEGs are converted to palette PNGs at the same boundary without changing their dimensions. This happens after the input reaches the MCP, so it does not raise the inline request cap — use the ref/upload flow for a large source file.

Output works the same way in reverse: render_strip/render_project take an output field — "inline" (standard MCP image blocks), "urls" (uploaded under your own prefix and returned as short-lived resource_link blocks), or omit for auto (inline under ~200KB total, urls above). The model-visible structuredContent.panels contains ordered delivery descriptors rather than PNG base64; ChatGPT receives the complete gallery through widget-only result metadata. Programmatic clients read the standard MCP content blocks. Use "urls" for full-resolution work.

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.

Private uploaded screenshots and generated files are retained until deleted. Treat refs as short-lived and single-use, then call delete_assets({ refs: [...] }) when the files are no longer needed. Deleting a ref is irreversible and can make a saved project's referenced screenshot unavailable; public privacy disclosures must accurately state this retention model.

Prerequisites

  1. A ShotOps API token. Sign in at the Studio app → account menu → API tokens → create one. It looks like shotops_9Zq3Xr7T… 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. The first render downloads it automatically into Playwright's cache if it's missing; run npx playwright install chromium first to skip that one-time wait.

Run it

cd shotops-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 — and that first render also installs Chromium if it isn't cached yet, adding ~20-25s to that one call. GET /health is an unauthenticated liveness probe.

Connect ChatGPT (Developer Mode)

Deploy the hosted server at a public HTTPS origin, enable Developer Mode in ChatGPT, and create an app whose MCP server URL is https://<your-host>/mcp. Do not paste a personal shotops_ token into the app definition: ChatGPT discovers the protected-resource metadata from the endpoint and runs the server's OAuth + PKCE browser sign-in flow. This is a tool-only app — no widget or UI resource is required. User-attached PNGs and JPEGs enter through import_screenshot.

For public plugin submission, OpenAI supplies a domain-verification token after the MCP domain is entered in the Platform dashboard. Set it on the hosted server as OPENAI_APPS_CHALLENGE_TOKEN; the server then returns that exact value from /.well-known/openai-apps-challenge. With no token configured, the route returns 404. The server also exposes a synthetic, non-user PNG at /review-fixtures/sample-app-screen-v2.png so reviewers can reproduce attachment and URL-input tests without private fixture data or third-party hosting.

Connect Claude Code

claude mcp add --transport http shotops http://localhost:8788/mcp \
  --header "Authorization: Bearer shotops_…"

Then ask the agent to, e.g., "render these two screenshots into a 6.9″ App Store strip and emit a screenshot bundle for com.acme.app." Every request must carry the Authorization: Bearer shotops_… 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 shotops_… 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 shotops-appstore-upload.zip -d upload && cd upload
fastlane deliver        # signs in as YOU, previews, uploads screenshots to a draft — never submits

Hosted

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

https://mcp.shotops.dev/mcp

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

claude mcp add --transport http shotops https://mcp.shotops.dev/mcp \
  --header "Authorization: Bearer shotops_…"

The public MCP docs have the same instructions for Cursor/CI, plus where to create a token — they're 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)

shotops-mcp serves from its own Vercel project on https://mcp.shotops.dev (health: /health, MCP endpoint: /mcp). docs/reference/ops-runbook.md §Deploy owns the procedure — the notes here are only what is specific to this package.

  • Deploy: run the repo's deploy script from the repo root; deploys run locally, and a push to main is a production deploy. Do not hand-run a bare vercel deploy. Vercel is the only hosted target — the Fly app was destroyed in #68.
  • Runtime env lives on the shotops-mcp Vercel project, not in this repo: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, and MCP_PUBLIC_URL (pins the public hostname as the OAuth issuer), plus OPENAI_APPS_CHALLENGE_TOKEN while verifying the MCP domain for public ChatGPT submission — nothing Apple-related, ever, by construction. Vercel env applies at the NEXT deployment, never to the running one.
  • Verify after any deploy — a green deploy isn't proof the server answers MCP calls:
    cd shotops-mcp && SHOTOPS_MCP_TOKEN=… node verify-mcp.mjs https://mcp.shotops.dev/mcp
    Exercises the exact registered tool set, real render/bundle/project round-trips, signed upload refs, asset deletion, and asserts no-token / bad-token both get 401. It needs the Studio deployment live too (delete_project is a control-plane op) and leaves the account as it found it (#333). Add --artifact-dir <private-dir> for the ChatGPT portal handoff.
  • Logs / status: the shotops-mcp project in the Vercel dashboard.
  • Production builds through Nitro — nitro.config.ts (preset: 'node-server', vercel: { entryFormat: 'node' }) compiles the Express graph into dist-server/. Dockerfile builds the RETIRED Fly image only; it is pinned to mcr.microsoft.com/playwright:v1.61.1-noble to match the playwright npm version in package.json, so if you bump one, bump the other.
  • The build compiles the browser harness once (build:harness → harness-dist/) and the running server serves it from a loopback-only static server. Vite is build/parity tooling only and is pruned from production dependencies; mockup-engine stays reachable at build time so the harness can bundle the shared engine + GLB.
  • Hosted render capacity is load-bearing and history-rich. A render_strip boots headless Chromium + WebGL for seconds, inline in the request, and full-res multi-panel renders are slow by design — seconds per panel, one panel at a time. The function duration ceiling, the memory findings behind it, and the two distinct failure shapes (CPU starvation vs. OOM) are recorded in docs/reference/ops-runbook.md §Deploy. Never cut a memory or duration setting without a green verify-mcp.mjs run behind the claim. Client callers (agents, verify-mcp.mjs) should set a generous per-call timeout (≥180s) for render_strip/emit_bundle.

Local — npx shotops-mcp

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

claude mcp add shotops -- npx -y shotops-mcp

Chromium isn't bundled in the npm package (it's ~150MB). The first render on a machine without Playwright's Chromium installs it automatically into Playwright's cache, then renders. That first render takes ~20-25s longer, and its result note says so. Install it ahead of time to skip the wait:

npx playwright install chromium

Offline, that automatic download fails and the first render refuses with upstream_unavailable until you're back online.

Claude Desktop (macOS and Windows)

The same local server ships as a Claude Desktop extension, no terminal needed. Download https://shotops.dev/download/shotops.mcpb (always the latest version; a specific one is shotops-mcp-<version>.mcpb beside it) and double-click it. Leave the ShotOps API token setting empty to preview free without an account, or paste a token from the Studio app → account menu → API tokens to save projects and looks. The extension bundles everything except Chromium, which the first render downloads once as described above.

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

emit_bundle reads local panels off disk too — an unsigned local server has no account, so it can't produce output: "urls" refs. Once you are signed in on a trial or Pro, pass the on-disk PNGs straight to emit_bundle (same local-only { path } door as screenshots) to package a fastlane deliver zip with no upload and no cloud credits:

{ "bundleId": "com.acme.app", "panels": [{ "path": "/abs/panel-01.png" }, { "path": "/abs/panel-02.png" }] }

emit_bundle and full-resolution renders are the store-ready half of the offer, so they need that trial or Pro even here, where the compute is yours. Preview renders need neither an account nor cloud credits. account_status reports which side of the line this process is on.

Saving without a token: a new save_project made from local { path } PNGs creates a private pending claim. It uploads only that saved project's byte-free record and raw source PNGs, returns status: "pending_claim" plus claimId and openUrl, and deliberately returns no projectId yet. Give the openUrl to the user: after one Google sign-in they own the project, with its screenshots, and the link expires after 7 days. An existing project cannot be updated without the token that owns it. Non-path inputs are refused rather than creating a blank claim.

Ordinary render_strip / emit_bundle calls still upload nothing — pixels and zips are written straight to your disk, and no screenshot leaves the machine. Other account reads/writes (read_look, save_look, read_project, render_project, share links) still need a token or hosted connection. A token-backed local render_project reads the cloud manifest through the Studio control plane, downloads only the resolved cells, and renders locally:

| | Local (npx shotops-mcp) | Hosted (shotops_… token) | |---|---|---| | Render compute | your machine ($0, no timeout, no cloud credits on any plan) | our servers — spends your ShotOps cloud credits: 1 per preview panel, 4 per full-resolution panel | | Preview render | free, no account | free on an account; 3 per 30 days, ≤5 panels, without one | | Full-resolution render, any emit_bundle | trial or Pro (costs no cloud credits) | trial or Pro, and spends cloud credits | | Auth | none to preview; a token for account state and store-ready output | account + API token | | Saved looks / editable projects / project read+re-render / share links | new project: pending claim; existing/account state: token (below); share links: hosted | ✅ | | Screenshot input | local { path } off disk | request_screenshot_upload → { ref } |

Optional: bridge a local render into your hosted account

Connect your account and save_project / read_look / save_look / read_project / render_project start working too — rendering still happens locally and spends no cloud credits, and a trial or Pro account unlocks full-resolution output and emit_bundle on this machine. A deliberate save also uploads its local source PNGs privately so the project reopens with screenshots on another device; normal renders and exports never upload.

Sign in through your browser, once per machine:

npx shotops-mcp login     # opens ShotOps in your browser, saves the token it gets back
npx shotops-mcp whoami    # which account is connected, and its plan
npx shotops-mcp logout    # revokes that token and removes it from this machine

The token lands in ~/.shotops/credentials.json, readable only by you, and every later npx shotops-mcp picks it up — nothing to paste into a client config. It is an ordinary ShotOps API token: revoke it any time from Studio → account menu → API tokens.

For CI, or to point one client at a different account, pass the token explicitly instead:

SHOTOPS_TOKEN=shotops_… claude mcp add shotops -- npx -y shotops-mcp
# or: npx shotops-mcp --token shotops_…

--token wins over SHOTOPS_TOKEN, which wins over a saved browser login — so a token in the environment is never quietly shadowed by whoever last signed in on that machine.

Local Apple release setup

Before preparing an App Store release, validate the Apple tooling and API key already on this machine:

npx shotops-mcp release setup
# non-interactive form:
npx shotops-mcp release setup \
  --issuer-id "$APP_STORE_CONNECT_ISSUER_ID" \
  --key-id "$APP_STORE_CONNECT_KEY_ID" \
  --key-path /secure/AuthKey_ABC123DEFG.p8

The command requires an owner-only .p8, Fastlane, and Apple Transporter; parses the key through Fastlane; and makes read-only App Store Connect app-list requests to prove the key's real access. It does not install or upgrade anything. Only Issuer ID, Key ID, and the canonical key path are saved in ~/.shotops/release.json (or $SHOTOPS_CONFIG_DIR/release.json); a failed rerun leaves the last valid setup untouched.

For CI, create secret inputs named APP_STORE_CONNECT_ISSUER_ID, APP_STORE_CONNECT_KEY_ID, and APP_STORE_CONNECT_PRIVATE_KEY. The setup command names these inputs but never prints their values.

Prepare a deterministic desired release

Once signed in to ShotOps, assemble the saved owner project and repository into one local, content-addressed workspace — before reading or changing App Store Connect:

npx shotops-mcp release prepare --project "My project"

# Optional signed build and an explicit partial scope:
npx shotops-mcp release prepare \
  --project proj_123 \
  --build build/MyApp.ipa \
  --locales en-US,de-DE \
  --outputs iphone-6-9

The command renders every authored locale and selected output by default with the same engine as Studio and the MCP. --locales or --outputs deliberately narrows the run and records every omission as a partial scope. It writes desired-release.json plus the rendered PNGs under ~/.shotops/release-workspaces/<desired-id>/ (or --output <directory>), with user-only permissions. Identical project, repository, scope, and build inputs produce the same manifest ID and pixels.

Repository input conventions are explicit so no Ruby is ever evaluated:

  • fastlane/metadata/<locale>/*.txt — supported localized Deliver metadata;
  • fastlane/metadata/*.txt and review_information/*.txt — app and review metadata;
  • fastlane/metadata/app_store_rating_config.json — Fastlane's current age-rating shape;
  • fastlane/metadata/submission_information.json — export/content compliance fields;
  • fastlane/metadata/app_review_attachment_file.txt — empty clears the attachment; otherwise it names a repository-relative supported attachment;
  • fastlane/app-previews/<locale>/* — .mov, .mp4, or .m4v, with the ASC device token in each filename (for example APP_IPHONE_67_01_demo.mp4).

Missing files mean unchanged; an existing supported empty text file means clear. Unknown files, fields, locales, outputs, unreadable artifacts, and Bundle ID disagreements fail closed. Fastfile and Deliverfile are never executed. Optional .ipa/supported .pkg bytes are hashed and referenced, never copied into ShotOps state. This step performs no ASC request, upload, version creation, signing, submission, or release.

Create the exact App Store Connect plan

Read the editable App Store version and bind it to the prepared workspace without changing Apple:

npx shotops-mcp release plan \
  --workspace ~/.shotops/release-workspaces/<desired-id> \
  --repo .

The command resolves the Bundle ID to one accessible app and one editable iOS version, then reads the supported metadata, review, age-rating, compliance, build, screenshot, and App Preview baseline. It writes a user-only release-plan.json containing every operation as a member of one closed, typed vocabulary — each carrying its own target, the precondition it needs, the sealed bytes it sends, the postcondition it must reach, and a redacted summary — plus a portable release-review.zip containing that exact canonical plan and its content-addressed screenshot bytes.

The plan hash-binds the project revision, desired bytes, normalized Fastlane inputs, Apple setup identity and policy version, so later approval cannot drift onto different state. Its approval window is one hour; the plan itself stays readable for as long as its sealed workspace exists. Concurrency is per resource rather than one hash of the whole app: an unrelated App Store edit is reported without invalidating the plan, while a change to a resource the plan writes — or to the app, version or App Store state it targets — blocks before any write.

release-plan-envelope.json is the separate redacted form safe for a later ShotOps coordination step: it carries keyed commitments and digests, never metadata values, review credentials, local paths, Apple credentials, or asset bytes. Identifiers are committed under a per-plan key that stays on your machine, so the envelope cannot be used to confirm a guess at your Bundle ID, Apple key ID or version string. This command talks directly to Apple with GET requests only; it never uploads, creates a version, commits, submits, or sends the private key to ShotOps.

Review the exact plan locally

From the plan directory, open its read-only local browser review:

npx shotops-mcp release review

To review an artifact downloaded from owner-controlled CI, pass it directly:

npx shotops-mcp release review release-review.zip

The command verifies the exact plan hash, artifact manifest, and every embedded screenshot before opening a nonce-protected URL on 127.0.0.1 with a random port. The page shows target app and team fingerprint, Bundle ID, version, the approval window, complete or partial scope, every before/after/clear, build, review, rating, compliance, preview, screenshot order/replacement/omission, unchanged operation, and the operations excluded by the plan. Partial-scope and no-build warnings cannot be dismissed. It loads no remote resource, sends nothing, and has no edit or approval action. A plan past its approval window opens read-only and says why; corrupt, edited, or content-mismatched inputs fail before a server is started.

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

shotops-mcp/
  src/
    server.ts       hosted HTTP entry point
    local.ts        local stdio server and release CLI entry point
    nitroApp.ts     Nitro adapter for the hosted app
    packagePaths.ts stable runtime asset anchor for source and bundled execution
    tools/          registration and tool handlers; shared dependency contract
    contracts/      schemas, instructions, result envelopes and tool metadata
    project/        project/Look resolution, persistence and refinement adapters
    render/         screenshot intake, rendering and output delivery
    production/     authorization, charging and durable production workflows
    release/        local release preparation, review, commit and recovery
    auth/           OAuth, caller authentication, consent and anonymous access
    runtime/        server/dependency wiring, process state, environment and widgets
  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
  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

Tests and fixtures live beside their subjects. Shared services import each other directly; tools/tools.ts only registers handlers. Folder placement rules live in the file-moves skill.