appmockupgenerator-mcp 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,151 @@
1
- # Temporary Holding Version
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.
1
+ # AppMockup MCP server
2
+
3
+ Turn raw app screenshots into App Store and Google Play marketing screenshots, with device frame, background and headline, without leaving Claude Code, Cursor, VS Code or Claude Desktop.
4
+
5
+ ```
6
+ You: Make App Store screenshots from the PNGs in ./screenshots
7
+ Agent: Found 5 screenshots. Proposed titles: 1. "Plan your week in seconds" …
8
+ This will use 5 credits (you have 70). Go ahead?
9
+ You: Yes
10
+ Agent: Done. Saved to ./appmockup-output/20261101-104512/01-plan-your-week-in-seconds.png …
11
+ ```
12
+
13
+ The server runs locally on your machine. It reads only the image files you point it at, uploads them to AppMockup, and saves the finished PNGs back into your project. It uses the credits on your AppMockup account: 1 credit per generated screenshot, the same as the web app. Results also appear in your web History.
14
+
15
+ ## 1. Get your API key
16
+
17
+ 1. Sign in at [appmockupgenerator.com](https://appmockupgenerator.com). New accounts get free credits.
18
+ 2. Verify your email address if you haven't (check your inbox for the link).
19
+ 3. Open **Studio → API & MCP** in the sidebar, or go to [appmockupgenerator.com/studio/api-keys](https://appmockupgenerator.com/studio/api-keys).
20
+ 4. Enter a name (e.g. "MacBook – Claude Code") and click **Create key**.
21
+ 5. Copy the key (`amk_live_…`). **It is shown only once.** If you lose it, revoke it and create a new one.
22
+
23
+ Treat the key like a password: anyone who has it can spend your credits. Don't commit it to git. You can have up to 5 active keys and revoke any of them instantly from the same page.
24
+
25
+ ## 2. Add the server to your AI client
26
+
27
+ Requires **Node.js 18.17+** (`node -v`).
28
+
29
+ **Claude Code**
30
+
31
+ ```bash
32
+ claude mcp add appmockup --scope user --env APPMOCKUP_API_KEY=amk_live_YOUR_KEY -- npx -y appmockupgenerator-mcp
33
+ ```
34
+
35
+ **Claude Desktop**: Settings → Developer → Edit Config (`claude_desktop_config.json`):
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "appmockup": {
41
+ "command": "npx",
42
+ "args": ["-y", "appmockupgenerator-mcp"],
43
+ "env": { "APPMOCKUP_API_KEY": "amk_live_YOUR_KEY" }
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ **Cursor**: `~/.cursor/mcp.json` (same JSON as Claude Desktop).
50
+
51
+ **VS Code**: `.vscode/mcp.json`, or run "MCP: Open User Configuration":
52
+
53
+ ```json
54
+ {
55
+ "servers": {
56
+ "appmockup": {
57
+ "type": "stdio",
58
+ "command": "npx",
59
+ "args": ["-y", "appmockupgenerator-mcp"],
60
+ "env": { "APPMOCKUP_API_KEY": "amk_live_YOUR_KEY" }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ Restart the client, then ask: *"Check my AppMockup account."* You should see your credit balance.
67
+
68
+ On Windows, if `npx` isn't found, use `"command": "cmd", "args": ["/c", "npx", "-y", "appmockupgenerator-mcp"]`.
69
+
70
+ ## 3. Use it
71
+
72
+ - "Make App Store screenshots from the PNGs in ./fastlane/screenshots"
73
+ - "Create Play Store screenshots for these 4 images, Vibrant Gradient style, in German"
74
+ - "Capture my iOS Simulator screens and turn them into store screenshots"
75
+ - Claude Code: type `/` and pick the `create_store_screenshots` prompt from the appmockup server for a guided flow.
76
+
77
+ ### Where screenshots come from
78
+
79
+ Before generating, the assistant asks you which source to use:
80
+
81
+ 1. **Your screenshot files (recommended).** You choose exactly which 4–8 screens appear, in which order. Point at files or a folder; `find_screenshots` lists candidate images so you can pick.
82
+ 2. **A running simulator/emulator.** The assistant asks you to open each screen you want, then captures **one screen per step** (`xcrun simctl` for the iOS Simulator on macOS, `adb` for Android). The AI never decides on its own which screens to capture.
83
+
84
+ ### Flow
85
+
86
+ 1. `choose_screenshot_source`: asks you files vs. simulator.
87
+ 2. `preview_titles` (free, optional): proposes a headline and subtitle per screenshot. Edit any of them.
88
+ 3. `generate_screenshots`: shows the credit cost and asks you to confirm, then runs in the background.
89
+ 4. `get_job`: reports progress, then the saved file paths. A `manifest.json` with titles and order is written next to the PNGs.
90
+
91
+ ## Tools
92
+
93
+ | Tool | What it does | Credits |
94
+ |---|---|---|
95
+ | `check_account` | Key status, credits, plan | 0 |
96
+ | `list_options` | Platforms, styles, languages, limits | 0 |
97
+ | `choose_screenshot_source` | Asks you: files or simulator | 0 |
98
+ | `find_screenshots` | Lists screenshot-sized images in a folder | 0 |
99
+ | `list_devices` | Booted iOS Simulators / Android emulators | 0 |
100
+ | `capture_simulator` | Saves the current screen of one device | 0 |
101
+ | `preview_titles` | Proposed marketing copy per screenshot | 0 |
102
+ | `generate_screenshots` | Creates the store screenshots (background job) | 1 per screenshot |
103
+ | `get_job` | Progress and results (long-polls up to 55 s) | 0 |
104
+ | `cancel_job` | Stops a job; unstarted screenshots aren't charged | 0 |
105
+
106
+ Platforms: `ios`, `android`, `ipad`, `duo` (iPhone Duo is on the Pro and Studio plans). Up to 10 screenshots per run, 20 MB each, PNG/JPEG/WebP.
107
+
108
+ ## Configuration
109
+
110
+ | Variable | Required | Description |
111
+ |---|---|---|
112
+ | `APPMOCKUP_API_KEY` | yes | Your key from the API & MCP page |
113
+ | `APPMOCKUP_OUTPUT_DIR` | no | Where to save results. Default: `<workspace>/appmockup-output/<timestamp>`, or `~/AppMockup` when the client has no workspace |
114
+
115
+ ## Security
116
+
117
+ - **The key stays local.** It is read from the environment, sent only in the `X-API-Key` header over HTTPS to `api.appmockupgenerator.com`, and never logged or shown to the model. AppMockup stores only a keyed hash (HMAC) of it, never the key itself.
118
+ - **Only images leave your machine.** Files are checked by their magic bytes (PNG/JPEG/WebP) and size before anything is read or uploaded. A path to any other kind of file is refused, even if a prompt asks for it.
119
+ - **Fixed destinations.** Uploads go only to AppMockup's Cloudflare R2 storage, and downloads come only from `cdn.appmockupgenerator.com`. Redirects are refused.
120
+ - **No shell.** Simulator capture runs `xcrun`/`adb` with fixed arguments and validated device ids.
121
+ - **Spending needs your consent.** Generation states the credit cost and needs your confirmation. Clients that support MCP elicitation show a dialog; others require the assistant to ask you first.
122
+ - **Server-side limits.** Requests are rate-limited per key, credits are deducted atomically, and failed generations are refunded automatically. Revoking a key takes effect within a minute.
123
+
124
+ ## Troubleshooting
125
+
126
+ Every error has a code and a "How to fix" line. Common ones:
127
+
128
+ | Code | Meaning | Fix |
129
+ |---|---|---|
130
+ | `CONFIG_MISSING_API_KEY` | No key in the MCP config | Add `APPMOCKUP_API_KEY` to `env` and restart the client |
131
+ | `CONFIG_INVALID_API_KEY` / `API_KEY_INVALID` | Key mistyped or deleted | Copy the full key again, or create a new one |
132
+ | `API_KEY_REVOKED` | Key was revoked | Create a new key and update the config |
133
+ | `INSUFFICIENT_CREDITS` | Not enough credits for this run | Buy credits at appmockupgenerator.com/pricing, or select fewer screenshots |
134
+ | `EMAIL_NOT_VERIFIED` | Account email not verified | Click the link in your verification email |
135
+ | `PLAN_REQUIRED` | Option needs a higher plan (e.g. iPhone Duo) | Upgrade or choose another platform |
136
+ | `RATE_LIMITED` | Too many requests | Wait for the time shown, then retry |
137
+ | `FILE_NOT_IMAGE` / `IMAGE_TOO_SMALL` / `FILE_TOO_LARGE` | Input file problem | Use full-resolution PNG/JPEG/WebP screenshots, 20 MB max |
138
+ | `SIMULATOR_NOT_RUNNING` / `SIMULATOR_TOOL_MISSING` | No booted device, or no Xcode/adb | Start the simulator/emulator, or use files instead |
139
+ | `TIMEOUT` during generation | The server didn't answer in time | Check History before retrying, so you don't pay twice |
140
+ | `SESSION_EXPIRED` | Title preview is older than ~55 minutes | Run `preview_titles` again |
141
+
142
+ Debug with the MCP Inspector: `npm run build && npm run inspect`.
143
+
144
+ ## Development
145
+
146
+ ```bash
147
+ npm install
148
+ npm run build
149
+ node scripts/smoke-test.mjs ../public # offline tool checks
150
+ node scripts/pipeline-test.mjs a.png b.png c.png # full pipeline against a mocked API
151
+ ```
package/dist/api.js ADDED
@@ -0,0 +1,275 @@
1
+ import { VERSION, requireApiKey } from './config.js';
2
+ import { AppMockupError, API_KEYS_URL, HISTORY_URL, PRICING_URL, toAppMockupError } from './errors.js';
3
+ import { imageInfo } from './images.js';
4
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
5
+ /** Errors that happened before the server could have processed anything. */
6
+ function isPreRequestNetworkError(err) {
7
+ const cause = err?.cause;
8
+ const code = cause?.code ?? err?.code;
9
+ return code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'UND_ERR_CONNECT_TIMEOUT';
10
+ }
11
+ function semverLt(a, b) {
12
+ const pa = a.split('.').map(Number);
13
+ const pb = b.split('.').map(Number);
14
+ for (let i = 0; i < 3; i++) {
15
+ const x = pa[i] ?? 0;
16
+ const y = pb[i] ?? 0;
17
+ if (x !== y)
18
+ return x < y;
19
+ }
20
+ return false;
21
+ }
22
+ /** Map an HTTP error from the AppMockup API to a helpful, user-facing error. */
23
+ function mapApiError(status, body, context) {
24
+ const code = typeof body.code === 'string' ? body.code : '';
25
+ const serverMsg = [body.message, body.error, body.details].find((v) => typeof v === 'string' && v.trim());
26
+ const retryAfter = Number(body.retryAfterSeconds ?? body.resetsIn ?? 0) || undefined;
27
+ if (code === 'API_KEY_REVOKED') {
28
+ return new AppMockupError('API_KEY_REVOKED', 'This API key was revoked.', `Create a new key at ${API_KEYS_URL} and update APPMOCKUP_API_KEY in your MCP config.`);
29
+ }
30
+ if (status === 401 || code === 'API_KEY_INVALID' || code === 'API_KEY_MISSING') {
31
+ if (code === 'ACCOUNT_NOT_FOUND') {
32
+ return new AppMockupError('ACCOUNT_NOT_FOUND', 'The account linked to this API key no longer exists.', `Sign in at ${API_KEYS_URL} and create a new key.`);
33
+ }
34
+ return new AppMockupError('API_KEY_INVALID', 'AppMockup rejected the API key.', `Copy a valid key from ${API_KEYS_URL} into APPMOCKUP_API_KEY and restart your AI client.`);
35
+ }
36
+ if (status === 402) {
37
+ const credits = Number(body.currentCredits ?? 0);
38
+ return new AppMockupError('INSUFFICIENT_CREDITS', `Not enough credits (you have ${credits}).`, `Each screenshot costs 1 credit. Buy credits or upgrade at ${PRICING_URL}, then retry.`, { credits });
39
+ }
40
+ if (status === 403 && code === 'EMAIL_NOT_VERIFIED') {
41
+ return new AppMockupError('EMAIL_NOT_VERIFIED', 'Your AppMockup email address is not verified.', 'Click the verification link in your inbox, then retry.');
42
+ }
43
+ if (status === 403) {
44
+ return new AppMockupError('PLAN_REQUIRED', serverMsg || 'This option is not included in your plan.', `See plans at ${PRICING_URL}, or choose another platform.`);
45
+ }
46
+ if (status === 429) {
47
+ const mins = retryAfter ? Math.ceil(retryAfter / 60) : undefined;
48
+ return new AppMockupError('RATE_LIMITED', serverMsg || 'Rate limit reached.', mins ? `Wait about ${mins} minute(s) and retry.` : 'Wait a few minutes and retry.', retryAfter ? { retryAfterSeconds: retryAfter } : undefined);
49
+ }
50
+ if (status === 404 && /session/i.test(serverMsg ?? '')) {
51
+ return new AppMockupError('SESSION_EXPIRED', 'The title preview expired (previews last 1 hour).', 'Run preview_titles again, or call generate_screenshots with the files directly.');
52
+ }
53
+ if (status === 404) {
54
+ return new AppMockupError('SERVICE_UNAVAILABLE', `${context}: this AppMockup API endpoint was not found.`, 'The server may not support MCP yet, or APPMOCKUP_API_URL points to the wrong environment (amk_test_ keys use /test-api, amk_live_ keys use /api).');
55
+ }
56
+ if (status === 400 || status === 413) {
57
+ return new AppMockupError('INVALID_INPUT', serverMsg ? `${context}: ${serverMsg}` : `${context}: the request was rejected.`, 'Check the screenshots and options, then retry.');
58
+ }
59
+ if (status === 503 || status === 502) {
60
+ return new AppMockupError('SERVICE_UNAVAILABLE', 'AppMockup is temporarily unavailable.', 'Wait a minute and retry.');
61
+ }
62
+ if (status === 504) {
63
+ return new AppMockupError('TIMEOUT', `${context} timed out on the server.`, `If a screenshot was created it appears in ${HISTORY_URL}; check there before retrying so you don't spend another credit.`);
64
+ }
65
+ return new AppMockupError('SERVER_ERROR', `${context} failed (HTTP ${status})${serverMsg ? `: ${serverMsg}` : ''}.`, 'Retry. Credits for failed generations are refunded automatically.');
66
+ }
67
+ export class ApiClient {
68
+ config;
69
+ accountChecked = false;
70
+ constructor(config) {
71
+ this.config = config;
72
+ }
73
+ async request(endpoint, context, opts) {
74
+ const apiKey = requireApiKey(this.config);
75
+ const url = `${this.config.apiBaseUrl}/${endpoint}`;
76
+ const maxAttempts = opts.idempotent ? 3 : 2;
77
+ for (let attempt = 1;; attempt++) {
78
+ let res;
79
+ try {
80
+ res = await fetch(url, {
81
+ method: 'POST',
82
+ headers: {
83
+ 'Content-Type': 'application/json',
84
+ 'X-API-Key': apiKey,
85
+ 'User-Agent': `appmockupgenerator-mcp/${VERSION} (node ${process.version}; ${process.platform})`,
86
+ 'X-AppMockup-Client': `mcp/${VERSION}`,
87
+ },
88
+ body: JSON.stringify(opts.body ?? {}),
89
+ signal: AbortSignal.timeout(opts.timeoutMs),
90
+ redirect: 'error',
91
+ });
92
+ }
93
+ catch (err) {
94
+ // Only repeat a non-idempotent call when the request provably never left.
95
+ const retryable = attempt < maxAttempts && (opts.idempotent || isPreRequestNetworkError(err));
96
+ if (retryable) {
97
+ await sleep(1000 * attempt * attempt);
98
+ continue;
99
+ }
100
+ const mapped = toAppMockupError(err);
101
+ if (mapped.code === 'TIMEOUT' && !opts.idempotent) {
102
+ throw new AppMockupError('TIMEOUT', `${context} took too long to respond.`, `It may still finish: check ${HISTORY_URL} before retrying so you don't spend another credit.`);
103
+ }
104
+ throw mapped;
105
+ }
106
+ const text = await res.text();
107
+ let body = {};
108
+ try {
109
+ body = text ? JSON.parse(text) : {};
110
+ }
111
+ catch { /* non-JSON (proxy error page) */ }
112
+ // 200 + { success:false, reason } is a soft "not available" answer (e.g. storage disabled).
113
+ if (res.ok && (body.success !== false || typeof body.reason === 'string'))
114
+ return body;
115
+ const err = mapApiError(res.status, body, context);
116
+ const retryAfter = Number(err.details?.retryAfterSeconds ?? 0);
117
+ const canRetry = attempt < maxAttempts && ((err.code === 'RATE_LIMITED' && body.code === 'RATE_LIMITED' && retryAfter > 0 && retryAfter <= 30) ||
118
+ // Auth-layer 503s happen before any credit is deducted.
119
+ (err.code === 'SERVICE_UNAVAILABLE' && (opts.idempotent || body.code === 'SERVICE_UNAVAILABLE')));
120
+ if (canRetry) {
121
+ await sleep(retryAfter > 0 ? retryAfter * 1000 : 2000 * attempt);
122
+ continue;
123
+ }
124
+ throw err;
125
+ }
126
+ }
127
+ async account() {
128
+ const acc = await this.request('mcp-account.php', 'Account check', { timeoutMs: 20_000, idempotent: true });
129
+ if (!this.accountChecked && acc.minClientVersion && semverLt(VERSION, acc.minClientVersion)) {
130
+ throw new AppMockupError('CLIENT_OUTDATED', `This AppMockup MCP server (v${VERSION}) is out of date.`, 'Restart your AI client; npx -y appmockupgenerator-mcp fetches the latest version.');
131
+ }
132
+ this.accountChecked = true;
133
+ return acc;
134
+ }
135
+ /**
136
+ * Upload one screenshot straight to storage via a presigned URL and return its
137
+ * CDN URL. Large images never pass through the PHP API this way.
138
+ * Returns null when server-side storage is disabled (caller falls back to base64).
139
+ */
140
+ async uploadScreenshot(data, mime) {
141
+ const presign = await this.request('asset-presign.php', 'Upload preparation', { body: { renditions: [{ rendition: 'source', contentType: mime, contentLength: data.length }] }, timeoutMs: 20_000, idempotent: true });
142
+ if (presign.success === false && presign.reason === 'storage_disabled')
143
+ return null;
144
+ const up = presign.uploads?.[0];
145
+ if (!up?.url || !up.publicUrl) {
146
+ throw new AppMockupError('UPLOAD_FAILED', 'The server did not return an upload URL.', 'Retry in a minute.');
147
+ }
148
+ // Only ever send user files to Cloudflare R2 and only expect our CDN back.
149
+ const target = new URL(up.url);
150
+ if (target.protocol !== 'https:' || !target.hostname.endsWith('.r2.cloudflarestorage.com')) {
151
+ throw new AppMockupError('UPLOAD_FAILED', 'Refused to upload to an unexpected storage host.', 'Contact support@appmockupgenerator.com.');
152
+ }
153
+ this.assertCdnUrl(up.publicUrl);
154
+ for (let attempt = 1; attempt <= 3; attempt++) {
155
+ try {
156
+ const res = await fetch(up.url, {
157
+ method: 'PUT',
158
+ headers: { ...(up.headers ?? {}), 'Content-Type': mime, 'Content-Length': String(data.length) },
159
+ body: new Uint8Array(data),
160
+ signal: AbortSignal.timeout(120_000),
161
+ redirect: 'error',
162
+ });
163
+ if (res.ok)
164
+ return up.publicUrl;
165
+ if (res.status === 403) {
166
+ throw new AppMockupError('UPLOAD_FAILED', 'The upload URL expired or was rejected.', 'Retry; a new upload URL is created each time.');
167
+ }
168
+ }
169
+ catch (err) {
170
+ if (err instanceof AppMockupError || attempt === 3) {
171
+ throw err instanceof AppMockupError ? err : new AppMockupError('UPLOAD_FAILED', 'Uploading the screenshot failed.', 'Check your connection and retry.');
172
+ }
173
+ }
174
+ await sleep(1000 * attempt);
175
+ }
176
+ throw new AppMockupError('UPLOAD_FAILED', 'Uploading the screenshot failed.', 'Check your connection and retry.');
177
+ }
178
+ async analyze(images, language) {
179
+ const res = await this.request('generate-mockup.php', 'Screenshot analysis', {
180
+ body: { mode: 'analyze', base64Images: images, language },
181
+ timeoutMs: 240_000,
182
+ idempotent: true,
183
+ });
184
+ if (!res.sessionId)
185
+ throw new AppMockupError('SERVER_ERROR', 'Analysis returned no session.', 'Retry.');
186
+ return { sessionId: res.sessionId, count: res.screenshotCount ?? images.length };
187
+ }
188
+ async getTitles(sessionId) {
189
+ const res = await this.request('generate-mockup.php', 'Loading titles', {
190
+ body: { mode: 'get-titles', sessionId }, timeoutMs: 20_000, idempotent: true,
191
+ });
192
+ return (res.items ?? []).map((it, i) => ({
193
+ index: Number(it.index ?? i), position: Number(it.position ?? i),
194
+ title: String(it.title ?? ''), subtitle: String(it.subtitle ?? ''),
195
+ }));
196
+ }
197
+ async updateTitles(sessionId, items) {
198
+ const res = await this.request('generate-mockup.php', 'Saving titles', {
199
+ body: { mode: 'update-titles', sessionId, items }, timeoutMs: 20_000, idempotent: true,
200
+ });
201
+ return Number(res.updated ?? 0);
202
+ }
203
+ /** Spends 1 credit (refunded server-side if generation fails). Never auto-retried after sending. */
204
+ async generate(args) {
205
+ const res = await this.request('generate-mockup.php', 'Screenshot generation', {
206
+ body: {
207
+ mode: 'generate',
208
+ supportsAssetUrls: true,
209
+ screenshotBase64: args.image,
210
+ sessionId: args.sessionId,
211
+ screenshotIndex: args.index,
212
+ platform: args.platform,
213
+ style: args.style,
214
+ customInstructions: args.customInstructions ?? '',
215
+ twistEnabled: args.twist,
216
+ twistType: args.twist ? 'ai-decides' : 'none',
217
+ originalFileName: args.fileName,
218
+ },
219
+ timeoutMs: 330_000,
220
+ idempotent: false,
221
+ });
222
+ const url = res.mockup?.assets?.origUrl;
223
+ if (!url) {
224
+ throw new AppMockupError('GENERATION_FAILED', 'The server finished without returning an image.', `Check ${HISTORY_URL}; if nothing is there, retry this screenshot.`);
225
+ }
226
+ this.assertCdnUrl(url);
227
+ return {
228
+ url,
229
+ title: res.mockup?.title ?? '',
230
+ subtitle: res.mockup?.subtitle ?? '',
231
+ width: Number(res.mockup?.assets?.width ?? 0),
232
+ height: Number(res.mockup?.assets?.height ?? 0),
233
+ creditsRemaining: typeof res.creditsRemaining === 'number' ? res.creditsRemaining : null,
234
+ };
235
+ }
236
+ /** Download a finished asset. Only our own CDN, bounded size, verified bytes. */
237
+ async download(url) {
238
+ this.assertCdnUrl(url);
239
+ for (let attempt = 1; attempt <= 3; attempt++) {
240
+ try {
241
+ const res = await fetch(url, { signal: AbortSignal.timeout(120_000), redirect: 'error' });
242
+ if (!res.ok)
243
+ throw new Error(`HTTP ${res.status}`);
244
+ const len = Number(res.headers.get('content-length') ?? 0);
245
+ if (len > 60 * 1024 * 1024)
246
+ throw new AppMockupError('DOWNLOAD_FAILED', 'Generated file is unexpectedly large.', 'Download it from History instead.');
247
+ const buf = Buffer.from(await res.arrayBuffer());
248
+ if (!imageInfo(buf))
249
+ throw new AppMockupError('DOWNLOAD_FAILED', 'Downloaded file is not a valid image.', `Download it from ${HISTORY_URL}.`);
250
+ return buf;
251
+ }
252
+ catch (err) {
253
+ if (err instanceof AppMockupError)
254
+ throw err;
255
+ if (attempt === 3) {
256
+ throw new AppMockupError('DOWNLOAD_FAILED', 'Could not download the generated screenshot.', `It was created successfully; download it from ${HISTORY_URL}.`, { url });
257
+ }
258
+ await sleep(1500 * attempt);
259
+ }
260
+ }
261
+ throw new AppMockupError('DOWNLOAD_FAILED', 'Could not download the generated screenshot.', `Download it from ${HISTORY_URL}.`);
262
+ }
263
+ assertCdnUrl(raw) {
264
+ let u;
265
+ try {
266
+ u = new URL(raw);
267
+ }
268
+ catch {
269
+ throw new AppMockupError('SERVER_ERROR', 'Server returned an invalid asset URL.', 'Retry.');
270
+ }
271
+ if (u.protocol !== 'https:' || !this.config.allowedCdnHosts.includes(u.hostname)) {
272
+ throw new AppMockupError('SERVER_ERROR', 'Server returned an asset on an unexpected host; refusing to use it.', 'Contact support@appmockupgenerator.com.');
273
+ }
274
+ }
275
+ }
package/dist/config.js ADDED
@@ -0,0 +1,49 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { AppMockupError, API_KEYS_URL } from './errors.js';
4
+ export const VERSION = '1.0.0';
5
+ const KEY_RE = /^amk_(live|test)_[A-Za-z0-9]{40}$/;
6
+ const PRODUCTION_API = 'https://api.appmockupgenerator.com/api';
7
+ const STAGING_API = 'https://api.appmockupgenerator.com/test-api';
8
+ /** amk_test_ keys belong to the staging project, so they default to the staging API. */
9
+ function defaultBaseUrl(apiKey) {
10
+ return apiKey?.startsWith('amk_test_') ? STAGING_API : PRODUCTION_API;
11
+ }
12
+ function parseBaseUrl(raw, apiKey) {
13
+ const value = (raw ?? '').trim() || defaultBaseUrl(apiKey);
14
+ let url;
15
+ try {
16
+ url = new URL(value);
17
+ }
18
+ catch {
19
+ throw new AppMockupError('CONFIG_INVALID', `APPMOCKUP_API_URL is not a valid URL.`, 'Remove APPMOCKUP_API_URL from your MCP config.');
20
+ }
21
+ const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
22
+ if (url.protocol !== 'https:' && !(local && url.protocol === 'http:')) {
23
+ throw new AppMockupError('CONFIG_INVALID', 'APPMOCKUP_API_URL must use https.', 'Remove APPMOCKUP_API_URL from your MCP config.');
24
+ }
25
+ return url.toString().replace(/\/+$/, '');
26
+ }
27
+ export function loadConfig(env = process.env) {
28
+ const apiKey = (env.APPMOCKUP_API_KEY ?? '').trim() || null;
29
+ const outputDir = (env.APPMOCKUP_OUTPUT_DIR ?? '').trim();
30
+ return {
31
+ apiKey,
32
+ apiBaseUrl: parseBaseUrl(env.APPMOCKUP_API_URL, apiKey),
33
+ outputDir: outputDir ? path.resolve(outputDir.replace(/^~(?=$|[\\/])/, os.homedir())) : null,
34
+ allowedCdnHosts: ['cdn.appmockupgenerator.com', 'cdn-staging.appmockupgenerator.com'],
35
+ };
36
+ }
37
+ /**
38
+ * The server still starts without a key so the client can list tools and
39
+ * explain setup; every tool that needs the API calls this first.
40
+ */
41
+ export function requireApiKey(config) {
42
+ if (!config.apiKey) {
43
+ throw new AppMockupError('CONFIG_MISSING_API_KEY', 'No AppMockup API key is configured.', `Create a key at ${API_KEYS_URL}, add it as APPMOCKUP_API_KEY in this MCP server's "env" config, then restart your AI client.`);
44
+ }
45
+ if (!KEY_RE.test(config.apiKey)) {
46
+ throw new AppMockupError('CONFIG_INVALID_API_KEY', 'APPMOCKUP_API_KEY does not look like an AppMockup key (expected amk_live_ followed by 40 characters).', `Copy the full key again from ${API_KEYS_URL}. Check for missing characters or extra quotes/spaces.`);
47
+ }
48
+ return config.apiKey;
49
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Every failure the user can hit is an AppMockupError with:
3
+ * - a stable machine code (for the model to branch on),
4
+ * - a plain-English message (what happened),
5
+ * - a hint (what to do next).
6
+ * Tool handlers convert it into an MCP `isError` result; nothing else leaks
7
+ * (no stack traces, no API keys, no raw server bodies).
8
+ */
9
+ export class AppMockupError extends Error {
10
+ code;
11
+ hint;
12
+ details;
13
+ constructor(code, message, hint, details) {
14
+ super(message);
15
+ this.code = code;
16
+ this.hint = hint;
17
+ this.details = details;
18
+ this.name = 'AppMockupError';
19
+ }
20
+ toText() {
21
+ const lines = [`Error [${this.code}]: ${this.message}`];
22
+ if (this.hint)
23
+ lines.push(`How to fix: ${this.hint}`);
24
+ if (this.details && Object.keys(this.details).length) {
25
+ lines.push(`Details: ${JSON.stringify(this.details)}`);
26
+ }
27
+ return lines.join('\n');
28
+ }
29
+ }
30
+ export const PRICING_URL = 'https://appmockupgenerator.com/pricing';
31
+ export const API_KEYS_URL = 'https://appmockupgenerator.com/studio/api-keys';
32
+ export const HISTORY_URL = 'https://appmockupgenerator.com/studio/history';
33
+ /** Normalise anything thrown into an AppMockupError without leaking internals. */
34
+ export function toAppMockupError(err) {
35
+ if (err instanceof AppMockupError)
36
+ return err;
37
+ const e = err;
38
+ if (e?.name === 'AbortError' || e?.name === 'TimeoutError') {
39
+ return new AppMockupError('TIMEOUT', 'The request timed out.', 'Check your connection and try again.');
40
+ }
41
+ const net = ['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNRESET', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT'];
42
+ if (e?.code && net.includes(e.code)) {
43
+ return new AppMockupError('NETWORK_ERROR', 'Could not reach AppMockup.', 'Check your internet connection, VPN or proxy, then retry.');
44
+ }
45
+ return new AppMockupError('SERVER_ERROR', 'Unexpected error.', 'Retry. If it keeps happening, contact support@appmockupgenerator.com.');
46
+ }