Skip to content

Commit b18e1ad

Browse files
v1.11.0: real screenshot backup & restore
snapshot_app_metadata gains includeScreenshots (downloads the actual images locally); new restore_screenshots re-uploads them so deleted screenshots can be brought back (replace:true for a true restore). Validated: backed up 50 images (40MB) and dry-run restore planned the re-upload.
1 parent 166d6a8 commit b18e1ad

6 files changed

Lines changed: 139 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project are documented here. The format follows
44
[Keep a Changelog](https://keepachangelog.com/) and the project uses
55
[Semantic Versioning](https://semver.org/).
66

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+
716
## [1.10.2] - 2026-06-02
817

918
### Changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ Full parameter reference: **[docs/TOOLS.md](docs/TOOLS.md)**.
265265
| `apps_review_status` | 🗂️ **Fleet review board** — every app's current version + state (waiting / in-review / rejected / ready) in one call |
266266
| `submit_for_review` / `release_version` / `set_phased_release` | 🚀 Submit a version to Apple review (full flow), release an approved build, and control phased rollout |
267267
| `doctor` | 🩺 Diagnose setup: Node, creds, key works, role capabilities, vendor number, Mac build tools, write mode |
268-
| `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** |
269269
| `release_readiness_check` | ✅ One-call **go/no-go report** — build, metadata, ASO, screenshots, compliance, TestFlight, reviews |
270270
| `aso_opportunity_report` / `portfolio_growth_report` | 📈 Rank the easiest **ASO wins** across all apps; portfolio snapshot of units sold per app |
271271
| `add_build_to_beta_group` / `submit_beta_review` | ✈️ Assign a build to a TestFlight group; submit for beta review |

docs/TOOLS.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -398,18 +398,27 @@ safe-mode write settings. Run this first when something isn't working.
398398

399399
### snapshot_app_metadata
400400
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).
404407

405408
### diff_app_metadata_snapshot
406409
- `appId` **(required)**, `snapshotFile` **(required)** — current vs snapshot diff.
407410

408411
### 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.
411413
- `appId` **(required)**, `snapshotFile` **(required)**, `dryRun`
412414

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+
413422
## Safe mode (guardrails)
414423

415424
Set these env vars to enforce limits at the **server** (blocked calls return a

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "appstore-api-mcp",
3-
"version": "1.10.2",
3+
"version": "1.11.0",
44
"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).",
55
"type": "module",
66
"bin": {

src/guardrails.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const WRITE_TOOLS = new Set([
3939
"upload_build",
4040
// snapshots
4141
"restore_app_metadata",
42+
"restore_screenshots",
4243
]);
4344

4445
// High-impact categories with their own opt-out env flags.

src/index.js

Lines changed: 113 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -163,14 +163,19 @@ const VERSION_LOC_FIELDS = [
163163
"supportUrl",
164164
];
165165

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 = {}) {
168172
const app = await client.get(`/apps/${appId}`);
169173
const snap = {
170174
appId,
171175
name: app.data.attributes.name,
172176
bundleId: app.data.attributes.bundleId,
173177
capturedAt: new Date().toISOString(),
178+
screenshotsBackedUp: !!opts.assetsDir,
174179
appInfo: null,
175180
version: null,
176181
screenshots: [],
@@ -194,16 +199,34 @@ async function collectAppMetadata(appId) {
194199
const o = {};
195200
for (const f of VERSION_LOC_FIELDS) o[f] = l.attributes[f] ?? null;
196201
snap.version.localizations[l.attributes.locale] = { id: l.id, ...o };
197-
// screenshot references (not the pixels)
198202
const sets = await client.getAll(`/appStoreVersionLocalizations/${l.id}/appScreenshotSets`);
199203
for (const s of sets) {
200204
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+
});
207230
}
208231
}
209232
}
@@ -2152,22 +2175,29 @@ const tools = [
21522175
{
21532176
name: "snapshot_app_metadata",
21542177
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.",
21562179
inputSchema: {
21572180
type: "object",
21582181
properties: {
21592182
appId: { type: "string" },
21602183
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+
},
21612188
},
21622189
required: ["appId"],
21632190
},
21642191
run: async (a) => {
2165-
const snap = await collectAppMetadata(a.appId);
21662192
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`);
21702199
writeFileSync(file, JSON.stringify(snap, null, 2));
2200+
const shotCount = snap.screenshots.reduce((n, s) => n + s.items.length, 0);
21712201
return {
21722202
file,
21732203
app: snap.name,
@@ -2176,7 +2206,12 @@ const tools = [
21762206
version: snap.version ? Object.keys(snap.version.localizations).length : 0,
21772207
},
21782208
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.",
21802215
};
21812216
},
21822217
},
@@ -2253,6 +2288,68 @@ const tools = [
22532288
return { dryRun: !!a.dryRun, app: current.name, restored: actions.length, actions };
22542289
},
22552290
},
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+
},
22562353

22572354
// ---- Recipes & aggregators (read-only) ----
22582355
{
@@ -2636,7 +2733,7 @@ Some things a great App Store listing needs live OUTSIDE App Store Connect and c
26362733
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.`;
26372734

26382735
const server = new Server(
2639-
{ name: "appstore-api-mcp", version: "1.10.2" },
2736+
{ name: "appstore-api-mcp", version: "1.11.0" },
26402737
{ capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS },
26412738
);
26422739

0 commit comments

Comments
 (0)