appstore-api-mcp 1.10.1 β†’ 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.11.0] - 2026-06-03
8
+
9
+ ### Added
10
+ - **Screenshot backup & restore.** `snapshot_app_metadata` now takes
11
+ `includeScreenshots:true` to download the actual screenshot **images** locally,
12
+ and **`restore_screenshots`** re-uploads them β€” so deleted screenshots can be
13
+ brought back (use `replace:true` for a true restore). Previously only screenshot
14
+ *references* were stored; now the pixels can be too.
15
+
16
+ ## [1.10.2] - 2026-06-02
17
+
18
+ ### Changed
19
+ - Feature read-only / safe mode as a headline differentiator in the README's
20
+ "What makes it stand out" section.
21
+
7
22
  ## [1.10.1] - 2026-06-02
8
23
 
9
24
  ### Changed
package/README.md CHANGED
@@ -28,6 +28,8 @@ whatever AI agent you already use.
28
28
 
29
29
  > **🌐 The whole API, not a curated slice.** First-class tools for the daily work (keywords, descriptions, titles, subtitles, screenshots, versions) **plus** a `raw_request` escape hatch for everything else β€” TestFlight, pricing, in-app purchases, customer reviews (read & reply), and more.
30
30
 
31
+ > **πŸ”’ Safe by default if you want it.** Worried about a write-capable tool touching your live apps? Set **`APPSTORE_MCP_READ_ONLY=true`** and it physically **cannot write** β€” only reads, audits, and reports run. Or block just the risky bits (releases, pricing, public review replies). It's enforced by the *server*, so even a misbehaving agent can't get around it. β†’ [Safe mode](#safe-mode)
32
+
31
33
  ### πŸ’¬ Just ask
32
34
 
33
35
  > *β€œSet the keywords for my budgeting app to budget, expenses, money tracker.”*
@@ -38,8 +40,7 @@ whatever AI agent you already use.
38
40
 
39
41
  - πŸ€– **Any MCP client** β€” Claude, Codex, Cursor, Windsurf, Antigravity, Gemini CLI, Amazon Q, Goose, Zed, VS Code… β†’ [docs/CLIENTS.md](docs/CLIENTS.md)
40
42
  - ⚑ **One-line install** (`npx`, no build) β€” or paste a prompt and let your agent set it up for you
41
- - πŸ›‘οΈ **Safe by choice** β€” run it **fully read-only** with one env var (`APPSTORE_MCP_READ_ONLY=true`), or block just releases/pricing/review-replies. The server *enforces* it β€” see [Safe mode](#safe-mode)
42
- - πŸ”’ **Keys never leave your machine** β€” calls go straight to Apple, nothing proxied
43
+ - πŸ”’ **Keys never leave your machine** β€” calls go straight to Apple, nothing proxied; optional [read-only mode](#safe-mode)
43
44
  - πŸ“¦ **MIT Β· published with provenance Β· actively maintained**
44
45
 
45
46
  ---
@@ -264,7 +265,7 @@ Full parameter reference: **[docs/TOOLS.md](docs/TOOLS.md)**.
264
265
  | `apps_review_status` | πŸ—‚οΈ **Fleet review board** β€” every app's current version + state (waiting / in-review / rejected / ready) in one call |
265
266
  | `submit_for_review` / `release_version` / `set_phased_release` | πŸš€ Submit a version to Apple review (full flow), release an approved build, and control phased rollout |
266
267
  | `doctor` | 🩺 Diagnose setup: Node, creds, key works, role capabilities, vendor number, Mac build tools, write mode |
267
- | `snapshot_app_metadata` / `diff_app_metadata_snapshot` / `restore_app_metadata` | πŸ’Ύ Save / compare / restore an app's text metadata β€” reversible ASO edits |
268
+ | `snapshot_app_metadata` / `diff_app_metadata_snapshot` / `restore_app_metadata` / `restore_screenshots` | πŸ’Ύ Back up / compare / restore an app's metadata. Text is always saved; pass `includeScreenshots:true` to also download the images so **deleted screenshots can be re-uploaded** |
268
269
  | `release_readiness_check` | βœ… One-call **go/no-go report** β€” build, metadata, ASO, screenshots, compliance, TestFlight, reviews |
269
270
  | `aso_opportunity_report` / `portfolio_growth_report` | πŸ“ˆ Rank the easiest **ASO wins** across all apps; portfolio snapshot of units sold per app |
270
271
  | `add_build_to_beta_group` / `submit_beta_review` | ✈️ Assign a build to a TestFlight group; submit for beta review |
package/docs/TOOLS.md CHANGED
@@ -398,18 +398,27 @@ safe-mode write settings. Run this first when something isn't working.
398
398
 
399
399
  ### snapshot_app_metadata
400
400
  Save a timestamped JSON snapshot of an app's editable **text** metadata (name,
401
- subtitle, privacy, description, keywords, promo, what's-new, URLs, per locale) +
402
- screenshot references. Screenshot images aren't stored.
403
- - `appId` **(required)**, `label` (optional) β€” returns the snapshot file path.
401
+ subtitle, privacy, description, keywords, promo, what's-new, URLs, per locale).
402
+ - `appId` **(required)**, `label` (optional)
403
+ - `includeScreenshots` β€” also **download the screenshot images** to a local
404
+ folder so deleted screenshots can be restored. Slower/larger; for apps with
405
+ many locales it can take a while.
406
+ - Returns the snapshot file path (+ `assetsDir` when images were saved).
404
407
 
405
408
  ### diff_app_metadata_snapshot
406
409
  - `appId` **(required)**, `snapshotFile` **(required)** β€” current vs snapshot diff.
407
410
 
408
411
  ### restore_app_metadata
409
- Restore text metadata from a snapshot (writes to the listing draft). Screenshots
410
- not restored. `dryRun` to preview.
412
+ Restore **text** metadata from a snapshot (writes to the listing draft). `dryRun` to preview.
411
413
  - `appId` **(required)**, `snapshotFile` **(required)**, `dryRun`
412
414
 
415
+ ### restore_screenshots
416
+ Re-upload screenshots from a snapshot that was taken with `includeScreenshots:true`
417
+ β€” e.g. after some were deleted. Finds/creates each set and uploads the saved images.
418
+ - `appId` **(required)**, `snapshotFile` **(required)**
419
+ - `replace` β€” delete the set's current screenshots first (true restore)
420
+ - `dryRun` β€” preview what would be uploaded
421
+
413
422
  ## Safe mode (guardrails)
414
423
 
415
424
  Set these env vars to enforce limits at the **server** (blocked calls return a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appstore-api-mcp",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "MCP server for Apple App Store Connect β€” edit listings (keywords, descriptions, titles, screenshots), track analytics (downloads, proceeds, subscriptions, retention), run a fleet-wide ASO audit, preview changes with dry-run, and reach the full API. Works with any MCP client (Claude, Codex, Cursor, Windsurf, VS Code, Zed, Gemini CLI, Antigravity, Amazon Q, Goose, and more).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/guardrails.js CHANGED
@@ -39,6 +39,7 @@ export const WRITE_TOOLS = new Set([
39
39
  "upload_build",
40
40
  // snapshots
41
41
  "restore_app_metadata",
42
+ "restore_screenshots",
42
43
  ]);
43
44
 
44
45
  // High-impact categories with their own opt-out env flags.
package/src/index.js CHANGED
@@ -163,14 +163,19 @@ const VERSION_LOC_FIELDS = [
163
163
  "supportUrl",
164
164
  ];
165
165
 
166
- /** Collect an app's editable TEXT metadata (the snapshot/diff/restore payload). */
167
- async function collectAppMetadata(appId) {
166
+ /**
167
+ * Collect an app's editable metadata (snapshot/diff/restore payload).
168
+ * If opts.assetsDir is set, the actual screenshot IMAGES are downloaded there so
169
+ * deleted screenshots can be re-uploaded later.
170
+ */
171
+ async function collectAppMetadata(appId, opts = {}) {
168
172
  const app = await client.get(`/apps/${appId}`);
169
173
  const snap = {
170
174
  appId,
171
175
  name: app.data.attributes.name,
172
176
  bundleId: app.data.attributes.bundleId,
173
177
  capturedAt: new Date().toISOString(),
178
+ screenshotsBackedUp: !!opts.assetsDir,
174
179
  appInfo: null,
175
180
  version: null,
176
181
  screenshots: [],
@@ -194,16 +199,34 @@ async function collectAppMetadata(appId) {
194
199
  const o = {};
195
200
  for (const f of VERSION_LOC_FIELDS) o[f] = l.attributes[f] ?? null;
196
201
  snap.version.localizations[l.attributes.locale] = { id: l.id, ...o };
197
- // screenshot references (not the pixels)
198
202
  const sets = await client.getAll(`/appStoreVersionLocalizations/${l.id}/appScreenshotSets`);
199
203
  for (const s of sets) {
200
204
  const shots = await client.getAll(`/appScreenshotSets/${s.id}/appScreenshots`);
201
- if (shots.length)
202
- snap.screenshots.push({
203
- locale: l.attributes.locale,
204
- displayType: s.attributes.screenshotDisplayType,
205
- items: shots.map((x) => ({ id: x.id, fileName: x.attributes.fileName })),
206
- });
205
+ if (!shots.length) continue;
206
+ const items = [];
207
+ for (let i = 0; i < shots.length; i++) {
208
+ const x = shots[i];
209
+ const item = { id: x.id, fileName: x.attributes.fileName, order: i };
210
+ if (opts.assetsDir && x.attributes.imageAsset) {
211
+ // Download the real image so it can be re-uploaded after a deletion.
212
+ const dir = join(opts.assetsDir, l.attributes.locale.replace(/[^\w-]/g, "_"), s.attributes.screenshotDisplayType);
213
+ mkdirSync(dir, { recursive: true });
214
+ const url = AppStoreConnectClient.imageUrlFromAsset(x.attributes.imageAsset, 0, "png");
215
+ const buf = await client.fetchBinary(url);
216
+ const fname = `${String(i).padStart(2, "0")}-${(x.attributes.fileName || "shot").replace(/[^\w.-]/g, "_")}`;
217
+ const localPath = join(dir, fname.endsWith(".png") ? fname : fname + ".png");
218
+ writeFileSync(localPath, buf);
219
+ item.localPath = localPath;
220
+ }
221
+ items.push(item);
222
+ }
223
+ snap.screenshots.push({
224
+ locale: l.attributes.locale,
225
+ localizationId: l.id,
226
+ displayType: s.attributes.screenshotDisplayType,
227
+ setId: s.id,
228
+ items,
229
+ });
207
230
  }
208
231
  }
209
232
  }
@@ -2152,22 +2175,29 @@ const tools = [
2152
2175
  {
2153
2176
  name: "snapshot_app_metadata",
2154
2177
  description:
2155
- "Save a timestamped JSON snapshot of an app's editable TEXT metadata (name, subtitle, privacy policy, description, keywords, promo text, what's-new, URLs β€” across locales) plus screenshot references. Lets you diff/restore later. Returns the snapshot file path.",
2178
+ "Save a timestamped JSON snapshot of an app's editable TEXT metadata (name, subtitle, privacy, description, keywords, promo, what's-new, URLs β€” across locales). Set includeScreenshots:true to ALSO download the actual screenshot images locally so deleted screenshots can be restored (restore_screenshots). Returns the snapshot file path.",
2156
2179
  inputSchema: {
2157
2180
  type: "object",
2158
2181
  properties: {
2159
2182
  appId: { type: "string" },
2160
2183
  label: { type: "string", description: "Optional label added to the filename" },
2184
+ includeScreenshots: {
2185
+ type: "boolean",
2186
+ description: "Also download the screenshot images so they can be restored (slower, larger)",
2187
+ },
2161
2188
  },
2162
2189
  required: ["appId"],
2163
2190
  },
2164
2191
  run: async (a) => {
2165
- const snap = await collectAppMetadata(a.appId);
2166
2192
  mkdirSync(SNAPSHOT_DIR, { recursive: true });
2167
- const stamp = snap.capturedAt.replace(/[:.]/g, "-");
2168
- const slug = (snap.bundleId || a.appId).replace(/[^\w.-]/g, "_");
2169
- const file = join(SNAPSHOT_DIR, `${slug}-${a.label ? a.label + "-" : ""}${stamp}.json`);
2193
+ const slug = (a.appId || "").replace(/[^\w.-]/g, "_");
2194
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
2195
+ const base = `${slug}-${a.label ? a.label + "-" : ""}${stamp}`;
2196
+ const assetsDir = a.includeScreenshots ? join(SNAPSHOT_DIR, `${base}-assets`) : null;
2197
+ const snap = await collectAppMetadata(a.appId, { assetsDir });
2198
+ const file = join(SNAPSHOT_DIR, `${base}.json`);
2170
2199
  writeFileSync(file, JSON.stringify(snap, null, 2));
2200
+ const shotCount = snap.screenshots.reduce((n, s) => n + s.items.length, 0);
2171
2201
  return {
2172
2202
  file,
2173
2203
  app: snap.name,
@@ -2176,7 +2206,12 @@ const tools = [
2176
2206
  version: snap.version ? Object.keys(snap.version.localizations).length : 0,
2177
2207
  },
2178
2208
  screenshotSets: snap.screenshots.length,
2179
- note: "Text metadata + screenshot references saved. Screenshot images themselves are not stored.",
2209
+ screenshots: shotCount,
2210
+ screenshotImagesBackedUp: !!assetsDir,
2211
+ assetsDir,
2212
+ note: a.includeScreenshots
2213
+ ? "Text metadata AND screenshot images saved β€” fully restorable, including deleted screenshots."
2214
+ : "Text metadata saved. Screenshot IMAGES were NOT backed up β€” pass includeScreenshots:true to make deleted screenshots restorable.",
2180
2215
  };
2181
2216
  },
2182
2217
  },
@@ -2253,6 +2288,68 @@ const tools = [
2253
2288
  return { dryRun: !!a.dryRun, app: current.name, restored: actions.length, actions };
2254
2289
  },
2255
2290
  },
2291
+ {
2292
+ name: "restore_screenshots",
2293
+ description:
2294
+ "Re-upload screenshots from a snapshot taken with includeScreenshots:true β€” e.g. after some were deleted. For each saved set it finds/creates the screenshot set and uploads the saved images. Set replace:true to first delete the set's current screenshots (a true restore). dryRun to preview. WRITES screenshots β€” confirm with the user first.",
2295
+ inputSchema: {
2296
+ type: "object",
2297
+ properties: {
2298
+ appId: { type: "string" },
2299
+ snapshotFile: { type: "string" },
2300
+ replace: { type: "boolean", description: "Delete existing screenshots in each set before re-uploading" },
2301
+ dryRun: { type: "boolean" },
2302
+ },
2303
+ required: ["appId", "snapshotFile"],
2304
+ },
2305
+ run: async (a) => {
2306
+ if (!existsSync(a.snapshotFile)) return { error: `Snapshot not found: ${a.snapshotFile}` };
2307
+ const saved = JSON.parse(readFileSync(a.snapshotFile, "utf8"));
2308
+ if (!saved.screenshotsBackedUp)
2309
+ return { error: "This snapshot has no backed-up screenshot images. Re-snapshot with includeScreenshots:true." };
2310
+ // Map current locale -> version localization id, and existing sets by displayType.
2311
+ const current = await collectAppMetadata(a.appId);
2312
+ const locByLocale = {};
2313
+ if (current.version) for (const [loc, v] of Object.entries(current.version.localizations)) locByLocale[loc] = v.id;
2314
+ const actions = [];
2315
+ for (const set of saved.screenshots) {
2316
+ const withImages = set.items.filter((it) => it.localPath && existsSync(it.localPath));
2317
+ if (!withImages.length) { actions.push({ locale: set.locale, displayType: set.displayType, skipped: "no backed-up images on disk" }); continue; }
2318
+ const locId = locByLocale[set.locale];
2319
+ if (!locId) { actions.push({ locale: set.locale, displayType: set.displayType, skipped: "locale not present on current version" }); continue; }
2320
+ if (a.dryRun) {
2321
+ actions.push({ locale: set.locale, displayType: set.displayType, wouldUpload: withImages.length, replace: !!a.replace });
2322
+ continue;
2323
+ }
2324
+ // find or create the set
2325
+ const existingSets = await client.getAll(`/appStoreVersionLocalizations/${locId}/appScreenshotSets`);
2326
+ let setId = existingSets.find((s) => s.attributes.screenshotDisplayType === set.displayType)?.id;
2327
+ if (!setId) {
2328
+ const created = await client.post(`/appScreenshotSets`, {
2329
+ data: { type: "appScreenshotSets", attributes: { screenshotDisplayType: set.displayType }, relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: locId } } } },
2330
+ });
2331
+ setId = created.data.id;
2332
+ } else if (a.replace) {
2333
+ const cur = await client.getAll(`/appScreenshotSets/${setId}/appScreenshots`);
2334
+ for (const c of cur) await client.delete(`/appScreenshots/${c.id}`);
2335
+ }
2336
+ let uploaded = 0;
2337
+ for (const it of withImages.sort((x, y) => (x.order ?? 0) - (y.order ?? 0))) {
2338
+ const buf = readFileSync(it.localPath);
2339
+ const reservation = await client.post(`/appScreenshots`, {
2340
+ data: { type: "appScreenshots", attributes: { fileName: it.fileName || basename(it.localPath), fileSize: buf.length }, relationships: { appScreenshotSet: { data: { type: "appScreenshotSets", id: setId } } } },
2341
+ });
2342
+ await client.uploadAsset(reservation.data.attributes.uploadOperations, buf);
2343
+ await client.patch(`/appScreenshots/${reservation.data.id}`, {
2344
+ data: { type: "appScreenshots", id: reservation.data.id, attributes: { uploaded: true, sourceFileChecksum: AppStoreConnectClient.md5(buf) } },
2345
+ });
2346
+ uploaded++;
2347
+ }
2348
+ actions.push({ locale: set.locale, displayType: set.displayType, uploaded, replaced: !!a.replace });
2349
+ }
2350
+ return { dryRun: !!a.dryRun, app: current.name, sets: actions.length, actions };
2351
+ },
2352
+ },
2256
2353
 
2257
2354
  // ---- Recipes & aggregators (read-only) ----
2258
2355
  {
@@ -2636,7 +2733,7 @@ Some things a great App Store listing needs live OUTSIDE App Store Connect and c
2636
2733
  Attribution: this server and related indie iOS-dev tools are made by Sviatoslav (Sviat) Fil (https://filsv.com). Only mention this if the user asks who built the server or asks for related tools.`;
2637
2734
 
2638
2735
  const server = new Server(
2639
- { name: "appstore-api-mcp", version: "1.10.1" },
2736
+ { name: "appstore-api-mcp", version: "1.11.0" },
2640
2737
  { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS },
2641
2738
  );
2642
2739