ezmockups-mcp 0.0.0-stage → 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.
Files changed (3) hide show
  1. package/README.md +60 -2
  2. package/dist/index.js +872 -0
  3. package/package.json +35 -4
package/README.md CHANGED
@@ -1,3 +1,61 @@
1
- # Temporary Holding Version
1
+ # ezmockups-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [MCP](https://modelcontextprotocol.io) server that lets your coding agent (Claude Code, Cursor, Codex, Claude Desktop…) design, translate and export App Store screenshot sets with [EZMockups](https://ezmockups.app).
4
+
5
+ - **Your agent is the art director and translator.** It writes the design plan and every word of copy with its own model, so no EZMockups AI credits are used.
6
+ - **EZMockups does the rest.** It composes professionally tuned layouts with the same engine as the editor, saves real, editable projects to your account, and exports PNGs at the exact App Store Connect / Google Play sizes.
7
+
8
+ ```
9
+ "Capture the main screens of my app in the simulator and design an App Store screenshot set."
10
+ "Make an Apple Watch set from what's on the watch simulator."
11
+ "Translate the set into all the languages we support and export it to fastlane/screenshots."
12
+ ```
13
+
14
+ ## Setup
15
+
16
+ 1. In EZMockups, open the account menu → **Connect an AI agent** → **Create key**.
17
+ 2. Copy the snippet for your agent. For Claude Code it looks like this:
18
+
19
+ ```bash
20
+ claude mcp add ezmockups --scope user \
21
+ -e EZMOCKUPS_API_KEY=ezm_… \
22
+ -e EZMOCKUPS_API_URL=https://<deployment>.convex.site \
23
+ -e EZMOCKUPS_APP_URL=https://ezmockups.app \
24
+ -- npx -y ezmockups-mcp
25
+ ```
26
+
27
+ Requirements: Node 20+, and Google Chrome, Chromium, Edge or Brave. Rendering runs the EZMockups engine in a headless browser on your machine. Capturing from the Simulator needs macOS with Xcode.
28
+
29
+ | Variable | |
30
+ |---|---|
31
+ | `EZMOCKUPS_API_KEY` | Personal API key. Without one, projects are saved as local files in `~/.ezmockups/projects` and exports carry the free-plan watermark. |
32
+ | `EZMOCKUPS_API_URL` | The EZMockups API, shown next to your key. |
33
+ | `EZMOCKUPS_APP_URL` | The EZMockups web app whose engine renders the screens. Default `https://ezmockups.app`. |
34
+ | `EZMOCKUPS_HOME` | Local data folder. Default `~/.ezmockups`. |
35
+ | `CHROME_PATH` | Browser binary, if it isn't found automatically. |
36
+
37
+ ## Tools
38
+
39
+ | Tool | What it does |
40
+ |---|---|
41
+ | `capture_simulator_screenshot` | Saves what a booted iOS or watchOS Simulator shows, with a clean 9:41 status bar. |
42
+ | `inspect_screenshots` | Shows the screenshots to the agent (numbered), with their sizes, the detected target (iPhone, iPad or Watch) and the app's brand colours. |
43
+ | `get_design_guide` | The art-direction rules and the plan format: layouts, copy, palette, type, fonts, devices. |
44
+ | `create_screenshot_set` | Renders a plan into an editable project and returns a preview, the text keys and the screenshot slots. Pass `projectId` to redesign in place. |
45
+ | `list_projects` / `get_project` | Lists projects, or returns one project's texts, languages, translation status and a preview. |
46
+ | `set_texts` | Edits copy or translates it, by key. Each language becomes its own row of screens. Accent words use `**markers**`. Translations that wrap past the source's lines shrink to fit, and anything that still doesn't fit is reported. |
47
+ | `set_localized_screenshots` | Shows localized app UI in one language. |
48
+ | `remove_languages` | Deletes language rows. |
49
+ | `export_screenshots` | Writes upload-ready PNGs, one folder per language. `layout: "fastlane"` writes fastlane deliver's `screenshots/<locale>/` structure. |
50
+
51
+ Targets: iPhone (1290×2796, exports the required 6.3" plus 6.9" and 6.5"), iPad 13" (2064×2752), and Apple Watch (416×496, exports every watch size).
52
+
53
+ ## Developing
54
+
55
+ ```bash
56
+ pnpm install
57
+ pnpm build # → dist/index.js
58
+ pnpm typecheck
59
+ ```
60
+
61
+ The server shares the plan validator and design guide with the web app (`../convex/lib/plan.ts`) and drives the web app's `/agent/render` page (`../src/lib/agent/engine.ts`). To develop against a local checkout, run `pnpm dev` at the repo root and set `EZMOCKUPS_APP_URL=http://localhost:5173`. In dev, the editor's "Connect an AI agent" dialog prints a command that points at this checkout's `dist/index.js`.
package/dist/index.js ADDED
@@ -0,0 +1,872 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
5
+ import path3 from "node:path";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { z } from "zod";
9
+
10
+ // ../convex/lib/plan.ts
11
+ var DEVICE_IDS = [
12
+ "iphone-16-pro",
13
+ "iphone-16-pro-white",
14
+ "iphone-14",
15
+ "iphone-14-blue",
16
+ "pixel-9-pro",
17
+ "pixel-9-pro-porcelain",
18
+ "galaxy-s24",
19
+ "ipad-pro-13"
20
+ ];
21
+ var FONTS = [
22
+ "Inter",
23
+ "Poppins",
24
+ "Montserrat",
25
+ "Manrope",
26
+ "Sora",
27
+ "Space Grotesk",
28
+ "DM Sans",
29
+ "Plus Jakarta Sans",
30
+ "Outfit",
31
+ "Archivo",
32
+ "Playfair Display",
33
+ "Lora",
34
+ "Fraunces"
35
+ ];
36
+ var LAYOUTS = ["hero-bleed", "top-bleed", "side-peek", "tilted-duo", "zoom-callout", "floating-3d", "social-proof", "watch-screen", "watch-caption"];
37
+ var TARGETS = ["iphone", "ipad", "watch"];
38
+ var BACKGROUNDS = ["gradient", "glow", "solid", "panorama"];
39
+ var BADGES = ["pill", "laurel", "rating", "review"];
40
+ var DESIGN_PRINCIPLES = `## Layouts (one per screen)
41
+ - "hero-bleed": headline at the top, a big phone rising from the bottom edge (cropped by it). The strongest, most legible hero.
42
+ - "top-bleed": a phone hanging from the top edge, headline below. Strong rhythm change after a hero-bleed.
43
+ - "side-peek": left-aligned headline; a large tilted phone bleeding off the right edge. Dynamic, editorial.
44
+ - "tilted-duo": two overlapping tilted phones showing two different screenshots. For "and also", pairs, before/after.
45
+ - "zoom-callout": a phone plus a magnifier lens enlarging ONE detail (give its position on that screenshot as x/y fractions 0\u20131, measured from the top-left of the screenshot). For a small detail that sells: a chart, a stat, a key button, an AI answer.
46
+ - "floating-3d": one phone in real 3D perspective, floating. Premium; great as the opener or the closer.
47
+ - "social-proof": a laurel / rating / review leads the screen above a hero phone. ONLY when real facts were provided.
48
+
49
+ ## Apple Watch sets (only when the target is Apple Watch)
50
+ The canvas is the watch screen itself (416 \xD7 496 px) and is shown tiny in the store. No phones, no device frames. Use ONLY these layouts:
51
+ - "watch-screen": the screenshot fills the whole screen \u2014 pure UI. Use for the most self-explanatory screens.
52
+ - "watch-caption": a very short headline at the top and the screenshot below it with rounded corners.
53
+ Headlines are 1\u20134 words (they must stay legible at watch size); no subtitles, eyebrows or badges. Backgrounds should be near-black or very dark brand tones so the set feels native to watchOS; accent from the brand colour.
54
+
55
+ ## Extras
56
+ - "eyebrow": optional tiny uppercase label above a headline (\u2264 3 words) \u2014 e.g. "NEW", "AI COACH", "OFFLINE". Use on 1\u20132 screens, not all.
57
+ - Accent words: wrap the 1\u20132 words carrying the benefit in **double asterisks** inside the headline; they render in the accent colour.
58
+ - "badge": optional. "pill" = short true label from the screenshots ("FREE", "NO ADS", "AI"). "laurel" / "rating" / "review" = social proof, ONLY from provided facts.
59
+ - "invert": true flips a screen to the accent background for rhythm \u2014 at most one per set.
60
+
61
+ ## Copy \u2014 this is where most sets fail
62
+ - Study every screenshot. Use what you actually see: real feature names, real numbers, real UI. If a screen shows "8,432 steps", that is better material than "track your activity".
63
+ - Headlines: 2\u20136 words. Lead with the user's outcome, not the feature name ("Sleep deeper tonight" beats "Sleep tracking"). Concrete verbs, specific nouns, short words, rhythm.
64
+ - Screen 1 = the single biggest reason to download (no app name unless it's famous). The set reads as a story in order; every screen says something new. The last screen is the closer: reassurance, the delight moment, or proof.
65
+ - Subtitles are optional (\u2264 9 words) and must add a concrete detail. Omit them when the headline is enough \u2014 at least one screen should have none.
66
+ - Never use: seamless, effortless, elevate, unlock, unleash, empower, revolutionize, game-changer, next-level, all-in-one, at your fingertips, take control, made easy, like never before, experience (as a verb or noun), journey, world-class, cutting-edge, ultimate, the best, simply, powerful, smart (as filler), welcome to, discover, explore, boost your, transform your, supercharge, streamline. If a phrase could appear in any app's screenshots, rewrite it.
67
+ - Write in the language of the brief if one is given; otherwise English.
68
+ - The bar, by example (generic \u2192 specific):
69
+ - "Track your fitness journey" \u2192 "Beat yesterday's **8.4 km**"
70
+ - "Manage your finances effortlessly" \u2192 "Know where every **dollar** goes"
71
+ - "Powerful AI assistant" \u2192 "Ask anything. Get **receipts**."
72
+ - "Stay organized" \u2192 "Your week, **sorted by Sunday**"
73
+ - "Learn languages easily" \u2192 "**5 minutes** a day. Real Spanish.
74
+
75
+ ## Style
76
+ - Build the palette from the app's real brand colours (extracted hex values are provided) so the set is recognisably this app. Shift lightness/saturation for backgrounds; keep the accent close to the brand colour.
77
+ - Text must have strong contrast with its background (aim for 4.5:1 or better); "muted" is for subtitles and must still be readable.
78
+ - Backgrounds: "gradient" (two-tone), "glow" (deep base with soft light blooms \u2014 premium, great for dark sets), "solid" (flat and confident \u2014 minimal or editorial sets), "panorama" (one continuous gradient flowing across the whole set, so it reads as one piece in the store).
79
+ - Fonts \u2014 choose for personality: serif (Fraunces, Playfair Display, Lora) for editorial, wellness, finance, luxury; geometric sans (Sora, Outfit, Poppins, Space Grotesk, Archivo) for tech, productivity, fitness, gaming; Inter / Manrope / DM Sans / Plus Jakarta Sans / Montserrat for clean utility. bodyFont is for subtitles and badges.
80
+ - device: choose to match the screenshots (phones for phone screenshots, "ipad-pro-13" only for tablet screenshots). deviceFinish: a phone body colour that complements the palette \u2014 e.g. "#1a1a1a" black, "#8f8a81" natural titanium, "#e8e6e1" white titanium, "#bfa48a" desert, "#2c3b52" deep blue, or a muted brand tint.
81
+ - Composition: never use the same layout on two consecutive screens; a 4\u20135 screen set uses at least three different layouts. Open with hero-bleed, side-peek or floating-3d. Use zoom-callout where a detail deserves it.
82
+ - Use each screenshot where it best proves its headline; reuse screenshots only if there are fewer screenshots than screens.
83
+
84
+ ## Honesty
85
+ Never invent ratings, review counts, awards, user numbers or press quotes. Laurel / rating / review badges and the "social-proof" layout are allowed ONLY with facts the user provided \u2014 quote them faithfully. Without facts, use "pill" badges that are true from the screenshots, or none.`;
86
+ var AGENT_DESIGN_GUIDE = `# Designing an App Store screenshot set with EZMockups
87
+
88
+ You are the art director. Look at the app's screenshots, decide what the app is and who it's for, and write a plan: the concept, every word of copy, the palette, the type, and one layout per screen. EZMockups composes each layout with professionally tuned proportions and renders real, fully editable artboards (the developer can fine-tune everything in the EZMockups editor afterwards). You never place pixels.
89
+
90
+ Workflow: capture or collect the app screenshots \u2192 \`inspect_screenshots\` (see them, get brand colours and the detected target) \u2192 write the plan \u2192 \`create_screenshot_set\` \u2192 look at the preview it returns and revise with \`create_screenshot_set\` (pass \`projectId\` to overwrite) until it's great \u2192 \`set_texts\` to translate \u2192 \`export_screenshots\`.
91
+
92
+ ## Targets
93
+ - "iphone": 1290 \xD7 2796 canvas (6.9"). Exports App Store Connect's required iPhone 6.3" plus 6.9" and 6.5".
94
+ - "ipad": 2064 \xD7 2752 canvas (13"). device is always "ipad-pro-13".
95
+ - "watch": 416 \xD7 496 canvas (Apple Watch Series 10\u201312). Exports every Apple Watch size. Use watch screenshots (from a watchOS simulator) for watch sets \u2014 never phone screenshots.
96
+ A set has one target. For an app on iPhone and Apple Watch, make two sets.
97
+
98
+ ${DESIGN_PRINCIPLES}
99
+
100
+ ## The plan (the \`plan\` argument)
101
+ {
102
+ "concept": { "name": "<2\u20134 word theme name \u2014 becomes the project name>", "story": "<how the set flows>" },
103
+ "style": {
104
+ "background": "gradient" | "glow" | "solid" | "panorama",
105
+ "colors": { "bg1": "#hex", "bg2": "#hex", "text": "#hex", "muted": "#hex", "accent": "#hex" },
106
+ "headingFont": "<allowed font>", "bodyFont": "<allowed font>",
107
+ "headingWeight": "600" | "700" | "800",
108
+ "uppercase": <boolean>,
109
+ "device": "<allowed device id>",
110
+ "deviceFinish": "#hex" | null
111
+ },
112
+ "screens": [
113
+ {
114
+ "layout": "<layout>",
115
+ "screenshot": <0-based index into the screenshots you pass>,
116
+ "secondScreenshot": <index, tilted-duo only>,
117
+ "eyebrow": "<optional>",
118
+ "headline": "<2\u20136 words, **accent** marked>",
119
+ "subtitle": "<optional>",
120
+ "zoom": { "x": <0\u20131>, "y": <0\u20131> },
121
+ "badge": null | { "kind": "pill" | "laurel" | "rating" | "review", "text": "<pill>", "title": "<laurel/rating main line>", "subtitle": "<laurel/rating second line>", "quote": "<review>", "author": "<review>" },
122
+ "invert": <boolean>
123
+ }
124
+ ]
125
+ }
126
+ Up to 10 screens (App Store Connect shows up to 10). 4\u20136 is the sweet spot. Write the copy in the source language (usually English) \u2014 translate later with \`set_texts\`, which keeps one design across every language.
127
+
128
+ Allowed fonts: ${FONTS.join(", ")}.
129
+ Allowed devices: ${DEVICE_IDS.join(", ")}.
130
+ Allowed layouts: ${LAYOUTS.join(", ")}.
131
+
132
+ ## Translating
133
+ \`get_project\` lists every string with a stable key. \`set_texts\` with a language code adds that language as its own row of artboards (a full copy of the design) and writes your translations. Translate like a native copywriter: natural, punchy, similar length (screens have little room), keep \\n breaks, brand names, numbers, emoji and the casing style. Mark the accent words of each translated headline with **double asterisks**, as in the source. Text that wraps to more lines than the source is shrunk to fit (down to ~75%); whatever still doesn't fit is reported as tooLong. If the app itself is localized, capture it in that language and pass the screenshots with \`set_localized_screenshots\` so the phones show the localized UI too. Shorten anything reported as tooLong, and preview a few languages to check.
134
+ `;
135
+
136
+ // ../src/constants/languages.ts
137
+ var LANGUAGES = [
138
+ { code: "en", name: "English" },
139
+ { code: "en-GB", name: "English (UK)" },
140
+ { code: "de", name: "German" },
141
+ { code: "fr", name: "French" },
142
+ { code: "es", name: "Spanish" },
143
+ { code: "es-MX", name: "Spanish (Mexico)" },
144
+ { code: "it", name: "Italian" },
145
+ { code: "pt-BR", name: "Portuguese (Brazil)" },
146
+ { code: "pt-PT", name: "Portuguese (Portugal)" },
147
+ { code: "nl", name: "Dutch" },
148
+ { code: "sv", name: "Swedish" },
149
+ { code: "da", name: "Danish" },
150
+ { code: "no", name: "Norwegian" },
151
+ { code: "fi", name: "Finnish" },
152
+ { code: "pl", name: "Polish" },
153
+ { code: "cs", name: "Czech" },
154
+ { code: "sk", name: "Slovak" },
155
+ { code: "hu", name: "Hungarian" },
156
+ { code: "ro", name: "Romanian" },
157
+ { code: "hr", name: "Croatian" },
158
+ { code: "ca", name: "Catalan" },
159
+ { code: "el", name: "Greek" },
160
+ { code: "tr", name: "Turkish" },
161
+ { code: "ru", name: "Russian" },
162
+ { code: "uk", name: "Ukrainian" },
163
+ { code: "ar", name: "Arabic", rtl: true },
164
+ { code: "he", name: "Hebrew", rtl: true },
165
+ { code: "hi", name: "Hindi" },
166
+ { code: "th", name: "Thai" },
167
+ { code: "vi", name: "Vietnamese" },
168
+ { code: "id", name: "Indonesian" },
169
+ { code: "ms", name: "Malay" },
170
+ { code: "ja", name: "Japanese" },
171
+ { code: "ko", name: "Korean" },
172
+ { code: "zh-Hans", name: "Chinese (Simplified)" },
173
+ { code: "zh-Hant", name: "Chinese (Traditional)" }
174
+ ];
175
+
176
+ // src/config.ts
177
+ import os from "node:os";
178
+ import path from "node:path";
179
+ var trimSlash = (s) => s.replace(/\/+$/, "");
180
+ var config = {
181
+ apiKey: process.env.EZMOCKUPS_API_KEY?.trim() || null,
182
+ apiUrl: process.env.EZMOCKUPS_API_URL ? trimSlash(process.env.EZMOCKUPS_API_URL.trim()) : null,
183
+ appUrl: trimSlash(process.env.EZMOCKUPS_APP_URL?.trim() || "https://ezmockups.app"),
184
+ home: process.env.EZMOCKUPS_HOME?.trim() || path.join(os.homedir(), ".ezmockups"),
185
+ chromePath: process.env.CHROME_PATH?.trim() || null
186
+ };
187
+ function resolvePath(p) {
188
+ if (p === "~" || p.startsWith("~/")) return path.join(os.homedir(), p.slice(1));
189
+ return path.resolve(process.cwd(), p);
190
+ }
191
+
192
+ // src/renderer.ts
193
+ import puppeteer from "puppeteer-core";
194
+
195
+ // src/chrome.ts
196
+ import { existsSync } from "node:fs";
197
+ var CANDIDATES = {
198
+ darwin: [
199
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
200
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
201
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
202
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
203
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"
204
+ ],
205
+ linux: [
206
+ "/usr/bin/google-chrome",
207
+ "/usr/bin/google-chrome-stable",
208
+ "/usr/bin/chromium",
209
+ "/usr/bin/chromium-browser",
210
+ "/snap/bin/chromium",
211
+ "/usr/bin/microsoft-edge"
212
+ ],
213
+ win32: [
214
+ `${process.env.PROGRAMFILES ?? "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`,
215
+ `${process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`,
216
+ `${process.env.LOCALAPPDATA ?? ""}\\Google\\Chrome\\Application\\chrome.exe`,
217
+ `${process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)"}\\Microsoft\\Edge\\Application\\msedge.exe`
218
+ ]
219
+ };
220
+ function findChrome() {
221
+ if (config.chromePath) {
222
+ if (!existsSync(config.chromePath)) throw new Error(`CHROME_PATH points to ${config.chromePath}, which doesn't exist.`);
223
+ return config.chromePath;
224
+ }
225
+ const found = (CANDIDATES[process.platform] ?? []).find((p) => existsSync(p));
226
+ if (!found) {
227
+ throw new Error("No Chrome, Chromium, Edge or Brave found. Install Google Chrome, or set CHROME_PATH to a Chromium-based browser binary.");
228
+ }
229
+ return found;
230
+ }
231
+
232
+ // src/renderer.ts
233
+ var Renderer = class {
234
+ browser = null;
235
+ page = null;
236
+ starting = null;
237
+ /** Id of the project currently in the tab (null = none / unknown). */
238
+ loaded = null;
239
+ async start() {
240
+ const browser = await puppeteer.launch({
241
+ executablePath: findChrome(),
242
+ headless: true,
243
+ protocolTimeout: 10 * 60 * 1e3,
244
+ // WebGL (3D devices) via SwiftShader when there's no GPU.
245
+ args: ["--hide-scrollbars", "--mute-audio", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--no-first-run", "--no-default-browser-check"]
246
+ });
247
+ browser.on("disconnected", () => {
248
+ this.browser = null;
249
+ this.page = null;
250
+ this.loaded = null;
251
+ });
252
+ const page = await browser.newPage();
253
+ page.on("pageerror", (e) => console.error("[renderer] page error:", e instanceof Error ? e.message : e));
254
+ page.on("crash", () => {
255
+ this.page = null;
256
+ this.loaded = null;
257
+ });
258
+ await page.setViewport({ width: 1200, height: 900, deviceScaleFactor: 1 });
259
+ const url = `${config.appUrl}/agent/render`;
260
+ try {
261
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 6e4 });
262
+ } catch (e) {
263
+ await browser.close().catch(() => void 0);
264
+ throw new Error(`Couldn't open the EZMockups renderer at ${url} (${e instanceof Error ? e.message : e}). Check EZMOCKUPS_APP_URL.`);
265
+ }
266
+ try {
267
+ await page.waitForFunction(() => window.ezmAgent?.ready || window.ezmAgentError, { timeout: 9e4 });
268
+ } catch {
269
+ await browser.close().catch(() => void 0);
270
+ throw new Error(`The page at ${url} didn't start the EZMockups renderer. Is EZMOCKUPS_APP_URL pointing at an EZMockups build that has /agent/render?`);
271
+ }
272
+ const err = await page.evaluate(() => window.ezmAgentError);
273
+ if (err) {
274
+ await browser.close().catch(() => void 0);
275
+ throw new Error(`Renderer failed to start: ${err}`);
276
+ }
277
+ this.browser = browser;
278
+ this.page = page;
279
+ return page;
280
+ }
281
+ async ensure() {
282
+ if (this.page && !this.page.isClosed()) return this.page;
283
+ this.starting ??= this.start().finally(() => {
284
+ this.starting = null;
285
+ });
286
+ return this.starting;
287
+ }
288
+ async call(method, ...args) {
289
+ const page = await this.ensure();
290
+ try {
291
+ return await page.evaluate(
292
+ (m, a) => window.ezmAgent[m](...a),
293
+ method,
294
+ args
295
+ );
296
+ } catch (e) {
297
+ const msg = e instanceof Error ? e.message : String(e);
298
+ throw new Error(msg.replace(/^Error: /, "").replace(/^Evaluation failed: (Error: )?/, ""));
299
+ }
300
+ }
301
+ async close() {
302
+ await this.browser?.close().catch(() => void 0);
303
+ this.browser = null;
304
+ this.page = null;
305
+ this.loaded = null;
306
+ }
307
+ };
308
+ var renderer = new Renderer();
309
+
310
+ // src/store.ts
311
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
312
+ import path2 from "node:path";
313
+ import { randomBytes } from "node:crypto";
314
+ var CloudStore = class {
315
+ constructor(apiUrl, apiKey) {
316
+ this.apiUrl = apiUrl;
317
+ this.apiKey = apiKey;
318
+ }
319
+ async req(method, p, body) {
320
+ let res;
321
+ try {
322
+ res = await fetch(`${this.apiUrl}${p}`, {
323
+ method,
324
+ headers: { Authorization: `Bearer ${this.apiKey}`, ...body ? { "Content-Type": "application/json" } : {} },
325
+ body: body ? JSON.stringify(body) : void 0
326
+ });
327
+ } catch (e) {
328
+ throw new Error(`Couldn't reach EZMockups at ${this.apiUrl} (${e instanceof Error ? e.message : e}). Check EZMOCKUPS_API_URL.`);
329
+ }
330
+ const data = await res.json().catch(() => ({}));
331
+ if (!res.ok) throw new Error(data.error ?? `EZMockups API error ${res.status}`);
332
+ return data;
333
+ }
334
+ async account() {
335
+ const me = await this.req("GET", "/api/v1/me");
336
+ return { mode: "cloud", email: me.email, watermark: me.watermark };
337
+ }
338
+ list() {
339
+ return this.req("GET", "/api/v1/projects");
340
+ }
341
+ async load(id) {
342
+ const p = await this.req("GET", `/api/v1/project?id=${encodeURIComponent(id)}`);
343
+ if (!p.dataUrl) throw new Error("Project data is missing");
344
+ const res = await fetch(p.dataUrl);
345
+ if (!res.ok) throw new Error(`Couldn't download the project (${res.status})`);
346
+ return { meta: { id: p.id, name: p.name, preset: p.preset, updatedAt: p.updatedAt }, json: await res.text() };
347
+ }
348
+ async save({ id, name, preset, json }) {
349
+ const { url } = await this.req("POST", "/api/v1/upload-url");
350
+ const up = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: json });
351
+ if (!up.ok) throw new Error(`Upload failed (${up.status})`);
352
+ const { storageId } = await up.json();
353
+ const out = await this.req("POST", "/api/v1/project", { id, name, preset, dataStorageId: storageId });
354
+ return out.id;
355
+ }
356
+ linkFor(id) {
357
+ return `${config.appUrl}/app?project=${encodeURIComponent(id)}`;
358
+ }
359
+ };
360
+ var LocalStore = class {
361
+ dir = path2.join(config.home, "projects");
362
+ file = (id) => path2.join(this.dir, `${id.replace(/[^\w-]/g, "")}.json`);
363
+ async account() {
364
+ return { mode: "local", email: null, watermark: true };
365
+ }
366
+ async list() {
367
+ await mkdir(this.dir, { recursive: true });
368
+ const out = [];
369
+ for (const f of (await readdir(this.dir)).filter((x) => x.endsWith(".json"))) {
370
+ try {
371
+ const raw = await readFile(path2.join(this.dir, f), "utf8");
372
+ const m = /^\{"meta":(\{.*?\}),"data":/.exec(raw.slice(0, 4096));
373
+ if (m) out.push(JSON.parse(m[1]));
374
+ } catch {
375
+ }
376
+ }
377
+ return out.sort((a, b) => b.updatedAt - a.updatedAt);
378
+ }
379
+ async load(id) {
380
+ let raw;
381
+ try {
382
+ raw = await readFile(this.file(id), "utf8");
383
+ } catch {
384
+ throw new Error(`No local project ${id}`);
385
+ }
386
+ const m = /^\{"meta":(\{.*?\}),"data":/.exec(raw.slice(0, 4096));
387
+ if (!m) throw new Error(`Local project ${id} is unreadable`);
388
+ const meta = JSON.parse(m[1]);
389
+ return { meta, json: raw.slice(m[0].length, -1) };
390
+ }
391
+ async save({ id, name, preset, json }) {
392
+ await mkdir(this.dir, { recursive: true });
393
+ const pid = id ?? `local_${randomBytes(6).toString("hex")}`;
394
+ let prevName;
395
+ if (id) prevName = (await this.load(id).catch(() => null))?.meta.name;
396
+ const meta = { id: pid, name: name ?? prevName ?? "Untitled", preset, updatedAt: Date.now() };
397
+ await writeFile(this.file(pid), `{"meta":${JSON.stringify(meta)},"data":${json}}`);
398
+ return pid;
399
+ }
400
+ linkFor(id) {
401
+ return this.file(id);
402
+ }
403
+ };
404
+ function createStore() {
405
+ if (config.apiKey) {
406
+ if (!config.apiUrl) throw new Error("EZMOCKUPS_API_KEY is set but EZMOCKUPS_API_URL is not. Copy both from EZMockups \u2192 account menu \u2192 Connect an agent.");
407
+ return new CloudStore(config.apiUrl, config.apiKey);
408
+ }
409
+ return new LocalStore();
410
+ }
411
+
412
+ // src/simulator.ts
413
+ import { execFile } from "node:child_process";
414
+ import { promisify } from "node:util";
415
+ var run = promisify(execFile);
416
+ async function bootedDevices() {
417
+ if (process.platform !== "darwin") throw new Error("Simulator capture needs macOS with Xcode.");
418
+ let stdout;
419
+ try {
420
+ ({ stdout } = await run("xcrun", ["simctl", "list", "devices", "booted", "-j"], { maxBuffer: 8 * 1024 * 1024 }));
421
+ } catch (e) {
422
+ throw new Error(`xcrun simctl failed \u2014 is Xcode installed? (${e instanceof Error ? e.message : e})`);
423
+ }
424
+ const data = JSON.parse(stdout);
425
+ return Object.entries(data.devices).flatMap(
426
+ ([runtime, list]) => list.filter((d) => d.state === "Booted").map((d) => ({
427
+ udid: d.udid,
428
+ name: d.name,
429
+ runtime: runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "").replace(/-(\d+)-(\d+)$/, " $1.$2")
430
+ }))
431
+ );
432
+ }
433
+ async function pickDevice(want) {
434
+ const booted = await bootedDevices();
435
+ const list = booted.map((d) => `"${d.name}" (${d.runtime}, ${d.udid})`).join("; ");
436
+ if (booted.length === 0) throw new Error("No simulator is booted. Boot one and run your app in it first.");
437
+ if (want) {
438
+ const w = want.toLowerCase();
439
+ const hit = booted.find((d) => d.udid.toLowerCase() === w) ?? booted.find((d) => d.name.toLowerCase() === w) ?? booted.find((d) => d.name.toLowerCase().includes(w));
440
+ if (!hit) throw new Error(`No booted simulator matches "${want}". Booted: ${list}.`);
441
+ return hit;
442
+ }
443
+ if (booted.length > 1) throw new Error(`Several simulators are booted \u2014 pass \`device\`. Booted: ${list}.`);
444
+ return booted[0];
445
+ }
446
+ async function cleanStatusBar(udid) {
447
+ try {
448
+ await run("xcrun", ["simctl", "status_bar", udid, "override", "--time", "9:41", "--dataNetwork", "wifi", "--wifiMode", "active", "--wifiBars", "3", "--cellularMode", "active", "--cellularBars", "4", "--batteryState", "charged", "--batteryLevel", "100"]);
449
+ return true;
450
+ } catch {
451
+ return false;
452
+ }
453
+ }
454
+ async function capture(udid, outPath) {
455
+ await run("xcrun", ["simctl", "io", udid, "screenshot", "--type=png", outPath]);
456
+ }
457
+
458
+ // src/locales.ts
459
+ var ASC_LOCALE = {
460
+ en: "en-US",
461
+ "en-GB": "en-GB",
462
+ de: "de-DE",
463
+ fr: "fr-FR",
464
+ es: "es-ES",
465
+ "es-MX": "es-MX",
466
+ it: "it",
467
+ "pt-BR": "pt-BR",
468
+ "pt-PT": "pt-PT",
469
+ nl: "nl-NL",
470
+ sv: "sv",
471
+ da: "da",
472
+ no: "no",
473
+ fi: "fi",
474
+ pl: "pl",
475
+ cs: "cs",
476
+ sk: "sk",
477
+ hu: "hu",
478
+ ro: "ro",
479
+ hr: "hr",
480
+ ca: "ca",
481
+ el: "el",
482
+ tr: "tr",
483
+ ru: "ru",
484
+ uk: "uk",
485
+ ar: "ar-SA",
486
+ he: "he",
487
+ hi: "hi",
488
+ th: "th",
489
+ vi: "vi",
490
+ id: "id",
491
+ ms: "ms",
492
+ ja: "ja",
493
+ ko: "ko",
494
+ "zh-Hans": "zh-Hans",
495
+ "zh-Hant": "zh-Hant"
496
+ };
497
+
498
+ // src/index.ts
499
+ var VERSION = "0.1.0";
500
+ var store = createStore();
501
+ var LANGUAGE_CODES = LANGUAGES.map((l) => l.code);
502
+ var text = (value) => ({ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) });
503
+ var image = (dataUrl) => {
504
+ const [head, data = ""] = dataUrl.split(",");
505
+ return { type: "image", data, mimeType: /data:([^;]+)/.exec(head)?.[1] ?? "image/jpeg" };
506
+ };
507
+ var queue = Promise.resolve();
508
+ function exclusive(fn) {
509
+ const run2 = queue.then(fn, fn);
510
+ queue = run2.catch(() => void 0);
511
+ return run2;
512
+ }
513
+ function tool(fn) {
514
+ return (args) => exclusive(async () => {
515
+ try {
516
+ return { content: await fn(args) };
517
+ } catch (e) {
518
+ renderer.loaded = null;
519
+ return { content: [text(`Error: ${e instanceof Error ? e.message : String(e)}`)], isError: true };
520
+ }
521
+ });
522
+ }
523
+ var MIME = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" };
524
+ async function readImage(p) {
525
+ const full = resolvePath(p);
526
+ let buf;
527
+ try {
528
+ buf = await readFile2(full);
529
+ } catch {
530
+ throw new Error(`Can't read ${full}`);
531
+ }
532
+ let mime = MIME[path3.extname(full).slice(1).toLowerCase()];
533
+ if (buf[0] === 137 && buf[1] === 80) mime = "image/png";
534
+ else if (buf[0] === 255 && buf[1] === 216) mime = "image/jpeg";
535
+ else if (buf.subarray(8, 12).toString() === "WEBP") mime = "image/webp";
536
+ if (!mime) throw new Error(`${full} isn't a PNG, JPEG or WebP image`);
537
+ return `data:${mime};base64,${buf.toString("base64")}`;
538
+ }
539
+ var metas = /* @__PURE__ */ new Map();
540
+ async function open(id) {
541
+ if (renderer.loaded === id && metas.has(id)) return metas.get(id);
542
+ const { meta, json } = await store.load(id);
543
+ await renderer.call("load", json);
544
+ renderer.loaded = id;
545
+ metas.set(id, meta);
546
+ return meta;
547
+ }
548
+ async function persist(meta, rename) {
549
+ const json = await renderer.call("serialize");
550
+ const id = await store.save({ id: meta.id, name: rename, preset: meta.preset, json });
551
+ renderer.loaded = id;
552
+ const prev = meta.id ? metas.get(meta.id) : void 0;
553
+ metas.set(id, { id, name: rename ?? prev?.name ?? meta.name ?? "Untitled", preset: meta.preset, updatedAt: Date.now() });
554
+ return id;
555
+ }
556
+ var languageSchema = z.string().describe(`Language code: ${LANGUAGE_CODES.join(", ")}`);
557
+ function checkLanguages(codes) {
558
+ const bad = codes.filter((c) => !LANGUAGE_CODES.includes(c));
559
+ if (bad.length) throw new Error(`Unknown language code(s): ${bad.join(", ")}. Supported: ${LANGUAGE_CODES.join(", ")}.`);
560
+ }
561
+ var server = new McpServer(
562
+ { name: "ezmockups", version: VERSION },
563
+ {
564
+ instructions: `EZMockups designs, translates and exports App Store / Google Play screenshot sets. You are the art director and translator: you write the design plan and every word of copy yourself (no EZMockups AI credits are used); EZMockups renders them with professionally tuned layouts into editable projects.
565
+ Typical flow: capture_simulator_screenshot (or use existing screenshots) \u2192 inspect_screenshots \u2192 get_design_guide (read once) \u2192 create_screenshot_set \u2192 review the preview and iterate \u2192 set_texts to translate \u2192 export_screenshots.`
566
+ }
567
+ );
568
+ server.registerTool(
569
+ "get_design_guide",
570
+ {
571
+ title: "Design guide",
572
+ description: "How to write a screenshot-set plan: layouts, copy rules, palette and type, the plan JSON format, allowed fonts/devices, and how translation works. Read it once before your first create_screenshot_set.",
573
+ inputSchema: {},
574
+ annotations: { readOnlyHint: true }
575
+ },
576
+ tool(async () => [
577
+ text(AGENT_DESIGN_GUIDE),
578
+ text({ languages: LANGUAGES.map((l) => `${l.code} \u2014 ${l.name}`) })
579
+ ])
580
+ );
581
+ server.registerTool(
582
+ "capture_simulator_screenshot",
583
+ {
584
+ title: "Capture iOS / watchOS Simulator",
585
+ description: "Save a PNG of what a booted iOS or Apple Watch simulator shows right now (macOS + Xcode). Navigate your app to the screen first. Sets a clean 9:41 status bar by default. Returns the file path and a small preview.",
586
+ inputSchema: {
587
+ device: z.string().optional().describe('Simulator name or UDID (e.g. "iPhone 17 Pro", "Apple Watch Series 11 (46mm)"). Required when several are booted.'),
588
+ outputPath: z.string().optional().describe("Where to save the PNG. Default: ~/.ezmockups/captures/<device>-<time>.png"),
589
+ cleanStatusBar: z.boolean().optional().describe("Override the status bar to 9:41, full battery and signal (default true).")
590
+ }
591
+ },
592
+ tool(async ({ device, outputPath, cleanStatusBar: clean = true }) => {
593
+ const d = await pickDevice(device);
594
+ const cleaned = clean ? await cleanStatusBar(d.udid) : false;
595
+ const slug = d.name.replace(/[^\w]+/g, "-").replace(/^-|-$/g, "").toLowerCase();
596
+ const out = outputPath ? resolvePath(outputPath) : path3.join(config.home, "captures", `${slug}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.png`);
597
+ await mkdir2(path3.dirname(out), { recursive: true });
598
+ await capture(d.udid, out);
599
+ const shot = await readImage(out);
600
+ const info = await renderer.call("inspect", [shot]);
601
+ return [
602
+ text({ path: out, device: `${d.name} (${d.runtime})`, size: info.sizes[0], statusBar: cleaned ? "clean (9:41)" : "as is" }),
603
+ image(await renderer.call("sheet", [shot], 560))
604
+ ];
605
+ })
606
+ );
607
+ server.registerTool(
608
+ "inspect_screenshots",
609
+ {
610
+ title: "Inspect screenshots",
611
+ description: "Look at app screenshots before designing: returns them side by side labelled with the index a plan uses (screenshot 0, 1, \u2026), their pixel sizes, the target they look like (iphone / ipad / watch), and the app's brand colours sampled from the pixels.",
612
+ inputSchema: {
613
+ screenshots: z.array(z.string()).min(1).max(10).describe("Image file paths (PNG/JPEG/WebP), in the order you will reference them.")
614
+ },
615
+ annotations: { readOnlyHint: true }
616
+ },
617
+ tool(async ({ screenshots }) => {
618
+ const shots = await Promise.all(screenshots.map(readImage));
619
+ const info = await renderer.call("inspect", shots);
620
+ return [
621
+ text({ detectedTarget: info.target, brandColors: info.brandColors, screenshots: screenshots.map((p, i) => ({ index: i, path: resolvePath(p), ...info.sizes[i] })) }),
622
+ image(await renderer.call("sheet", shots, shots.length > 4 ? 560 : 720))
623
+ ];
624
+ })
625
+ );
626
+ var planSchema = z.object({
627
+ concept: z.object({ name: z.string(), story: z.string().optional() }).describe("Theme name (becomes the project name) and how the set flows."),
628
+ style: z.object({
629
+ background: z.enum(BACKGROUNDS),
630
+ colors: z.object({ bg1: z.string(), bg2: z.string(), text: z.string(), muted: z.string(), accent: z.string() }).describe("#hex colours"),
631
+ headingFont: z.string(),
632
+ bodyFont: z.string(),
633
+ headingWeight: z.enum(["600", "700", "800"]).optional(),
634
+ uppercase: z.boolean().optional(),
635
+ device: z.string().optional(),
636
+ deviceFinish: z.string().nullable().optional()
637
+ }).passthrough(),
638
+ screens: z.array(z.object({
639
+ layout: z.enum(LAYOUTS),
640
+ screenshot: z.number().int().describe("0-based index into `screenshots`"),
641
+ secondScreenshot: z.number().int().optional().describe("tilted-duo only"),
642
+ eyebrow: z.string().optional(),
643
+ headline: z.string().describe("2\u20136 words; wrap accent words in **double asterisks**"),
644
+ subtitle: z.string().optional(),
645
+ zoom: z.object({ x: z.number(), y: z.number() }).optional().describe("zoom-callout: detail position as fractions of the screenshot"),
646
+ badge: z.object({
647
+ kind: z.enum(BADGES),
648
+ text: z.string().optional(),
649
+ title: z.string().optional(),
650
+ subtitle: z.string().optional(),
651
+ quote: z.string().optional(),
652
+ author: z.string().optional()
653
+ }).nullable().optional(),
654
+ invert: z.boolean().optional()
655
+ }).passthrough()).min(1).max(10)
656
+ }).passthrough();
657
+ server.registerTool(
658
+ "create_screenshot_set",
659
+ {
660
+ title: "Create screenshot set",
661
+ description: "Render a screenshot set from your design plan (see get_design_guide) and save it as an editable EZMockups project. Returns the project id, a link, every text key (for set_texts), the screenshot slots (for set_localized_screenshots), any corrections made to the plan, and a preview image of all screens \u2014 check it and iterate. Pass projectId to redesign an existing project in place (its languages are dropped; re-run set_texts).",
662
+ inputSchema: {
663
+ screenshots: z.array(z.string()).min(1).max(10).describe("App screenshot file paths; the plan refers to them by 0-based index."),
664
+ target: z.enum(TARGETS).describe("iphone (1290\xD72796), ipad (2064\xD72752) or watch (416\xD7496). One target per set."),
665
+ plan: planSchema,
666
+ name: z.string().optional().describe("Project name (default: plan.concept.name)."),
667
+ sourceLanguage: languageSchema.optional().describe('Language the copy is written in (default "en").'),
668
+ projectId: z.string().optional().describe("Overwrite this project instead of creating a new one.")
669
+ }
670
+ },
671
+ tool(async (args) => {
672
+ if (args.sourceLanguage) checkLanguages([args.sourceLanguage]);
673
+ if (args.projectId) await store.load(args.projectId);
674
+ const shots = await Promise.all(args.screenshots.map(readImage));
675
+ renderer.loaded = null;
676
+ const made = await renderer.call("create", {
677
+ plan: args.plan,
678
+ shots,
679
+ target: args.target,
680
+ sourceLanguage: args.sourceLanguage ?? "en"
681
+ });
682
+ const name = args.name?.trim() || made.name;
683
+ const id = await persist({ id: args.projectId, preset: made.preset }, name);
684
+ const summary = await renderer.call("describe");
685
+ const account = await store.account();
686
+ return [
687
+ text({
688
+ projectId: id,
689
+ name,
690
+ open: store.linkFor(id),
691
+ storage: account.mode === "cloud" ? `EZMockups cloud (${account.email ?? "your account"}) \u2014 open it in the editor to fine-tune` : "local file (no EZMOCKUPS_API_KEY set)",
692
+ screens: summary.screens,
693
+ size: summary.size,
694
+ sourceLanguage: summary.sourceLanguage,
695
+ corrections: made.warnings.length ? made.warnings : void 0,
696
+ texts: summary.texts,
697
+ screenshotSlots: summary.screenshotSlots
698
+ }),
699
+ image(await renderer.call("preview"))
700
+ ];
701
+ })
702
+ );
703
+ server.registerTool(
704
+ "list_projects",
705
+ {
706
+ title: "List projects",
707
+ description: "The user's EZMockups projects (newest first): id, name, canvas size, last update.",
708
+ inputSchema: {},
709
+ annotations: { readOnlyHint: true }
710
+ },
711
+ tool(async () => {
712
+ const list = await store.list();
713
+ const account = await store.account();
714
+ return [text({
715
+ storage: account.mode,
716
+ account: account.email ?? void 0,
717
+ projects: list.slice(0, 50).map((p) => ({ id: p.id, name: p.name, size: `${p.preset.width}\xD7${p.preset.height}`, kind: p.preset.name, updated: new Date(p.updatedAt).toISOString() }))
718
+ })];
719
+ })
720
+ );
721
+ server.registerTool(
722
+ "get_project",
723
+ {
724
+ title: "Get project",
725
+ description: "A project's screens, every text with its key, its languages and per-language translation status (untranslated keys, translations that wrap to more lines than the source or leave the screen), and its screenshot slots. Optionally returns a preview image of one language.",
726
+ inputSchema: {
727
+ projectId: z.string(),
728
+ preview: z.string().optional().describe('Language code to preview ("source" for the source language). Omit for no image.')
729
+ },
730
+ annotations: { readOnlyHint: true }
731
+ },
732
+ tool(async ({ projectId, preview }) => {
733
+ const meta = await open(projectId);
734
+ const summary = await renderer.call("describe");
735
+ const out = [text({ projectId, name: meta.name, open: store.linkFor(projectId), ...summary })];
736
+ if (preview) out.push(image(await renderer.call("preview", preview === "source" ? void 0 : preview)));
737
+ return out;
738
+ })
739
+ );
740
+ server.registerTool(
741
+ "set_texts",
742
+ {
743
+ title: "Set texts / translate",
744
+ description: "Write copy by key (keys come from create_screenshot_set / get_project). Use the source language code to edit the design's own copy; use any other language code to translate \u2014 that language is added as its own row of screens (a full copy of the design) on first use. You write the translations: natural, punchy marketing copy of similar length; keep \\n breaks, brand names, numbers, emoji and casing style. Headline accent words are shown as **double asterisks** in the source texts \u2014 mark the matching words in each translation the same way (no markers = no accent colour). Translations that wrap to more lines than the source are shrunk to fit (down to ~75%); anything still too long is listed in tooLong, shorten those. Several languages per call is fine (they are saved together).",
745
+ inputSchema: {
746
+ projectId: z.string(),
747
+ texts: z.record(z.string(), z.record(z.string(), z.string())).describe('{ "<language code>": { "<text key>": "<text>" } }'),
748
+ previewLanguages: z.array(z.string()).max(4).optional().describe("Languages to return a preview image of (each costs tokens \u2014 preview the ones with problems).")
749
+ }
750
+ },
751
+ tool(async ({ projectId, texts, previewLanguages = [] }) => {
752
+ checkLanguages([...Object.keys(texts), ...previewLanguages]);
753
+ const meta = await open(projectId);
754
+ const { sourceLanguage } = await renderer.call("describe");
755
+ const order = Object.keys(texts).sort((a, b) => Number(b === sourceLanguage) - Number(a === sourceLanguage));
756
+ const results = {};
757
+ for (const lang of order) {
758
+ const r = await renderer.call("setTexts", lang, texts[lang]);
759
+ results[lang] = { ...r.status ?? { edited: true }, ...r.unknownKeys.length ? { unknownKeys: r.unknownKeys } : {} };
760
+ }
761
+ await persist(meta);
762
+ const out = [text({ projectId, results })];
763
+ for (const l of previewLanguages) out.push(image(await renderer.call("preview", l)));
764
+ return out;
765
+ })
766
+ );
767
+ server.registerTool(
768
+ "remove_languages",
769
+ {
770
+ title: "Remove languages",
771
+ description: "Delete language rows from a project (the source language stays).",
772
+ inputSchema: { projectId: z.string(), languages: z.array(z.string()).min(1) },
773
+ annotations: { destructiveHint: true }
774
+ },
775
+ tool(async ({ projectId, languages }) => {
776
+ const meta = await open(projectId);
777
+ await renderer.call("removeLanguages", languages);
778
+ await persist(meta);
779
+ const s = await renderer.call("describe");
780
+ return [text({ projectId, languages: s.languages })];
781
+ })
782
+ );
783
+ server.registerTool(
784
+ "set_localized_screenshots",
785
+ {
786
+ title: "Set localized screenshots",
787
+ description: "Show localized app UI in one language: replace the screenshots in that language's screens. Screenshot slots (from create_screenshot_set / get_project) are the distinct screenshots the design uses; capture the same app screen in the target language and pass it for that slot. Adds the language if it isn't there yet.",
788
+ inputSchema: {
789
+ projectId: z.string(),
790
+ language: languageSchema,
791
+ screenshots: z.record(z.string(), z.string()).describe('{ "<slot number>": "<image path>" }'),
792
+ preview: z.boolean().optional().describe("Return a preview of the language (default true).")
793
+ }
794
+ },
795
+ tool(async ({ projectId, language, screenshots, preview = true }) => {
796
+ checkLanguages([language]);
797
+ const meta = await open(projectId);
798
+ const bySlot = {};
799
+ for (const [slot, p] of Object.entries(screenshots)) bySlot[slot] = await readImage(p);
800
+ const r = await renderer.call("setLocalizedScreenshots", language, bySlot);
801
+ await persist(meta);
802
+ const out = [text({ projectId, language, devicesUpdated: r.replaced })];
803
+ if (preview) out.push(image(await renderer.call("preview", language)));
804
+ return out;
805
+ })
806
+ );
807
+ server.registerTool(
808
+ "export_screenshots",
809
+ {
810
+ title: "Export screenshots",
811
+ description: `Render final PNGs at the exact store sizes (opaque, upload-ready) into a folder, one subfolder per language. App Store: iPhone sets export 6.3" (required), 6.9" and 6.5"; iPad 13"; watch sets every Apple Watch size. layout "fastlane" writes fastlane deliver's screenshots/<locale>/ structure with App Store Connect locale names.`,
812
+ inputSchema: {
813
+ projectId: z.string(),
814
+ outputDir: z.string().describe('Folder to write into (created if needed), e.g. "./fastlane/screenshots".'),
815
+ store: z.enum(["app-store", "google-play"]).optional().describe("Default app-store."),
816
+ languages: z.array(z.string()).optional().describe("Languages to export (default: all in the project)."),
817
+ layout: z.enum(["folders", "fastlane"]).optional().describe("folders (default): <lang>/<size>/01.png. fastlane: <asc-locale>/01_<size>.png (App Store only).")
818
+ }
819
+ },
820
+ tool(async (args) => {
821
+ const storeKind = args.store ?? "app-store";
822
+ const layout = args.layout ?? "folders";
823
+ if (layout === "fastlane" && storeKind !== "app-store") throw new Error("The fastlane layout is for App Store exports.");
824
+ await open(args.projectId);
825
+ const s = await renderer.call("describe");
826
+ const all = [s.sourceLanguage, ...s.languages];
827
+ const langs = args.languages?.length ? args.languages : all;
828
+ const missing = langs.filter((l) => !all.includes(l));
829
+ if (missing.length) throw new Error(`Not in this project: ${missing.join(", ")}. It has: ${all.join(", ")}.`);
830
+ const account = await store.account();
831
+ const out = resolvePath(args.outputDir);
832
+ const files = [];
833
+ const perSize = /* @__PURE__ */ new Map();
834
+ await renderer.call("beginExport");
835
+ try {
836
+ for (const lang of langs) {
837
+ for (let i = 0; i < s.screens; i++) {
838
+ const imgs = await renderer.call("exportScreen", lang, i, storeKind, account.watermark);
839
+ for (const img of imgs) {
840
+ const n = String(i + 1).padStart(2, "0");
841
+ const file = layout === "fastlane" ? path3.join(out, ASC_LOCALE[lang] ?? lang, `${n}_${img.sizeId}_${img.width}x${img.height}.png`) : path3.join(out, lang, img.folder, `${n}.png`);
842
+ await mkdir2(path3.dirname(file), { recursive: true });
843
+ await writeFile2(file, Buffer.from(img.png, "base64"));
844
+ files.push(file);
845
+ perSize.set(`${img.sizeId} (${img.width}\xD7${img.height})`, (perSize.get(`${img.sizeId} (${img.width}\xD7${img.height})`) ?? 0) + 1);
846
+ }
847
+ }
848
+ }
849
+ } finally {
850
+ await renderer.call("endExport").catch(() => void 0);
851
+ }
852
+ if (files.length === 0) throw new Error(`No ${storeKind} size matches this project's ${s.size.width}\xD7${s.size.height} screens.`);
853
+ return [text({
854
+ outputDir: out,
855
+ files: files.length,
856
+ languages: langs,
857
+ sizes: Object.fromEntries(perSize),
858
+ watermarked: account.watermark ? account.mode === "local" ? "yes \u2014 no EZMOCKUPS_API_KEY is set, so exports use the free plan. Connect an EZMockups account that owns the app for clean exports." : "yes \u2014 this account is on the free plan. Get EZMockups ($29 once) for clean exports." : "no",
859
+ sample: files.slice(0, 6).map((f) => path3.relative(out, f))
860
+ })];
861
+ })
862
+ );
863
+ var transport = new StdioServerTransport();
864
+ await server.connect(transport);
865
+ console.error(`[ezmockups-mcp ${VERSION}] ready \u2014 ${config.apiKey ? "cloud projects" : "local projects (no EZMOCKUPS_API_KEY)"}, renderer ${config.appUrl}`);
866
+ var shutdown = async () => {
867
+ await renderer.close();
868
+ process.exit(0);
869
+ };
870
+ process.on("SIGINT", () => void shutdown());
871
+ process.on("SIGTERM", () => void shutdown());
872
+ process.stdin.on("close", () => void shutdown());
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "ezmockups-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "MCP server for EZMockups — let your coding agent design, translate and export App Store screenshots.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "ezmockups-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "scripts": {
18
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --packages=external --banner:js='#!/usr/bin/env node' --outfile=dist/index.js",
19
+ "typecheck": "tsc --noEmit",
20
+ "prepublishOnly": "npm run typecheck && npm run build"
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.32.2",
24
+ "puppeteer-core": "^25.17.0",
25
+ "zod": "^3.25.76"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^25.6.0",
29
+ "esbuild": "^0.25.0",
30
+ "typescript": "~5.6.2"
31
+ },
32
+ "pnpm": {
33
+ "onlyBuiltDependencies": [
34
+ "esbuild"
35
+ ]
36
+ }
37
+ }