appshots-mcp
v0.3.0
Published
MCP server that composes App Store / Google Play screenshot projects and writes them as .appshots files for the AppShots editor.
Maintainers
Readme
AppShots MCP Server
Lets Claude Code / Codex design App Store & Google Play screenshots: compose the layout, look at what it
rendered, fix it, and export PNGs at the exact store pixel size — or save a .appshots project to finish by
hand in the AppShots editor.
Rendering runs the editor's real renderer in a headless browser, so what the model sees and what it exports are what the desktop app's Export button would produce.
Install
Claude Code:
claude mcp add appshots -- npx -y appshots-mcpCodex CLI — in ~/.codex/config.toml:
[mcp_servers.appshots]
command = "npx"
args = ["-y", "appshots-mcp"]Any other MCP client: run npx -y appshots-mcp as a stdio server. It needs local filesystem access
(it reads your screenshots and writes project files), so it runs on your machine — there is no hosted URL to point at.
The server prints nothing to stdout — that channel is the JSON-RPC stream — and reports failures as tool errors.
Requirements
- Node 18+ — that is all the authoring tools need.
- AppShots 1.3+ installed, for the rendering tools. It is the renderer: it already holds the editor, the device bezels and the font faces, so this package stays ~35 kB and downloads nothing.
This package is a thin entry point on purpose. It does not carry a copy of the editor, and rendering without the app is a developer fallback, not a supported path — see below.
APPSHOTS_APP points at an AppShots bundle in an unusual place. APPSHOTS_RENDERER=app|chrome forces a backend.
The installed app carries the bezels and font faces, so rendering works offline — except for CJK font faces,
which come from Google Fonts. When one fails to load, the render tools say so in missingFonts instead of
quietly handing back a screenshot set in the wrong typeface.
From a checkout
claude mcp add appshots -- npx tsx /path/to/appscreenshots/mcp/server.tsTools
| Tool | Purpose |
|---|---|
| list_options | Valid devices (with exact export pixel sizes), templates, bezel ids, font families, locale codes, shape kinds |
| create_project | New project: device, pages, base locale, layout template |
| open_project / save_project | Read / write .appshots (defaults to ~/Documents/AppShots/<name>.appshots) |
| describe_project | Settings + every layer with page, geometry (fractions and pixels) and id |
| configure_project | Rename, default locale, locale→font map, per-page backgrounds, page names |
| set_background | Solid or multi-stop gradient, whole band or one page |
| add_text | Headline/body text, one string per locale |
| add_frame | Device frame + the screenshot inside it |
| add_shape / add_image | Decorations, logos, badges |
| update_layer / remove_layer | Move, resize, restyle (raw merges any field from src/types.ts), delete |
| set_locales / set_text | Locale list and per-locale copy — translate in the model, no translation service needed |
| import_screenshots | Fill frames from a folder (fastlane screenshots/<locale>/…_01.png layout, en-US_01.png, or plain order) |
| render_preview | Render pages and return them as images — the model sees its own layout and iterates |
| export_screenshots | Write the final PNGs at exact store pixel size, in the fastlane deliver layout |
The loop that makes this work is add_* → render_preview → update_layer → render_preview → export_screenshots.
Without the preview step a model is composing blind.
Geometry is fractions, never pixels
Every coordinate and size is a fraction of one page: x: 0.14, width: 0.72 is a frame inset 14% from the page's left edge, three-quarters of a page wide. The same numbers compose correctly on a 410 px Watch and a 2880 px Mac, and the model can't emit an off-canvas layout by guessing wrong about the device. add_frame takes only width — the height follows the device's real aspect ratio. Passing a pixel value gets a tool error that explains the convention.
describe_project reports both units, and update_layer.raw is the escape hatch that writes raw Layer fields in their own units.
What it reuses
Nothing about the file format, device geometry or naming conventions is restated here — project.ts imports it from the app, and npm run mcp:build inlines those modules into the published bundle:
src/utils/appshotsFile.ts— the actual.appshotspacker/unpackersrc/utils/seed.ts— the layout templatessrc/constants/devices.ts,bezels.ts,shapeDefaults.ts,fonts.ts,locales.tssrc/utils/batchImport.ts—parseShotPath, the screenshot naming conventionsrc/utils/exporter.ts—withExportStage, the same render path the app's Export button and its App Store Connect upload use;src/headless.tsxmounts the realCanvasStageand calls it
Only two things are local, because they have no DOM here: imageSize.ts (pixel dimensions from file headers, replacing fileToAsset's Image decode) and the session/tool layer itself.
How rendering works
Two backends, one Renderer interface, so the tools never learn which is in use.
The desktop app (preferred, macOS): spawned as appshots --render-server — no Dock icon, no window. It
opens headless.html in a hidden webview and answers a line-delimited JSON protocol on stdio
(src-tauri/src/render.rs). Nothing is downloaded and no copy of the web build is needed: the app already
carries the editor, the bezels and the fonts. An installed app older than 1.3 is skipped by reading its
Info.plist, rather than being launched and waited on — spawning one would just open the editor.
A browser (fallback, opt-in): mcp/render.ts serves a web build from an ephemeral localhost port (ES
modules can't load over file://), opens headless.html in Chrome, and calls into the page. One browser and
page stay open across calls, and the project is only re-injected when it changes, so a multi-locale export
doesn't re-fetch fonts per image.
This path needs two things the package deliberately does not ship: a web build (APPSHOTS_DIST, or a checkout's
own dist/) and playwright-core (an optional peer). Bundling either would have added ~30 MB to every install
to serve a fallback most people never reach. It exists for this repo's tests and for platforms the app has not
shipped on yet.
Both mount the actual editor canvas and render through withExportStage. They cannot produce identical bytes —
WebKit and Chromium antialias text and dither gradients differently, worth ~0.4% mean channel difference — so
mcp/backends.test.mts asserts they agree to within rasterization noise, which a real layout drift would blow
straight past.
Develop & publish
Run these from the repo root:
npm run mcp:check # typecheck
npm run mcp:test # full suite, pinned to the browser backend
npm run mcp:test:app # the same suite, pinned to a locally built AppShots
npm run mcp:test:backends # the two backends must lay out identically
npm run mcp:build # bundle mcp/dist/server.js (the published artifact)
npm run mcp:web # optional: a local web build for the browser fallback
npm run mcp:test:dist # pack → install into a clean project → run the suite against it
npm run mcp # run the server by hand (it will just wait on stdin)mcp:test needs macOS sips for its screenshot fixtures; the app-backed runs need cargo build in src-tauri
first. APPSHOTS_RENDER_VISIBLE=1 shows the render window, which is the only way to watch what a
misbehaving backend is drawing.
To publish: bump version in mcp/package.json, then
npm run mcp:test:dist && cd mcp && npm publishmcp/dist/ and mcp/web/ are generated and gitignored; only dist/, README.md and LICENSE are published
(~35 kB). A test asserts web never reappears in files — publishing it would put the whole editor inside an
MIT package. The appshotsVersion field records which app version the bundle's inlined modules came from;
mcp:build keeps it in step.
