appstore-api-mcp 1.9.1 → 1.10.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/.env.example CHANGED
@@ -24,3 +24,12 @@ ASC_PRIVATE_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
24
24
  # Sales/finance/analytics reports also require a key with the Admin, Finance, or
25
25
  # Sales role — an App Manager key cannot read them.
26
26
  # ASC_VENDOR_NUMBER=
27
+
28
+ # Optional safe-mode guardrails (enforced by the server, not just the agent):
29
+ # APPSTORE_MCP_READ_ONLY=true # block ALL writes
30
+ # APPSTORE_MCP_ALLOW_RELEASE=false # block release_version / set_phased_release
31
+ # APPSTORE_MCP_ALLOW_PRICE_CHANGES=false # block set_app_price
32
+ # APPSTORE_MCP_ALLOW_REVIEW_REPLIES=false # block public review replies
33
+ # APPSTORE_MCP_ALLOW_EXTERNAL_TESTFLIGHT=false # block submit_beta_review
34
+ # Optional: where metadata snapshots are written (default ~/.appstore-api-mcp/snapshots)
35
+ # APPSTORE_MCP_SNAPSHOT_DIR=
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ 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.10.0] - 2026-06-02
8
+
9
+ ### Added
10
+ - **Safe mode (tool-level guardrails):** `APPSTORE_MCP_READ_ONLY` plus per-category
11
+ `APPSTORE_MCP_ALLOW_RELEASE` / `_PRICE_CHANGES` / `_REVIEW_REPLIES` /
12
+ `_EXTERNAL_TESTFLIGHT`. Blocked writes return a clear error — enforced by the
13
+ server, not just the agent.
14
+ - **`doctor`** — diagnose Node, credentials, key validity, report-role capability,
15
+ Vendor Number, Mac build tools, and the active write mode.
16
+ - **Metadata snapshots:** `snapshot_app_metadata`, `diff_app_metadata_snapshot`,
17
+ `restore_app_metadata` (text metadata; reversible ASO edits).
18
+ - **Test suite** (`npm test`, `node:test`): validation, guardrails, gzip/TSV
19
+ parsing, and 429-retry — locking in the safety guarantees.
20
+ - Docs: golden-output example for the readiness check, an ASO-tool-CSV recipe,
21
+ and a Safe-mode section in README/SECURITY.
22
+
23
+ ### Changed
24
+ - Extracted `src/validation.js` and `src/guardrails.js` (unit-tested modules).
25
+
7
26
  ## [1.9.1] - 2026-06-02
8
27
 
9
28
  ### Added
package/README.md CHANGED
@@ -216,6 +216,22 @@ All configuration is via environment variables.
216
216
 
217
217
  See [.env.example](.env.example) for a copy-paste template.
218
218
 
219
+ ### 🛡️ Safe mode (optional, recommended for shared/CI setups)
220
+
221
+ Tool-level guardrails — enforced by the server, not "please ask the agent." Set
222
+ any of these env vars to lock things down:
223
+
224
+ | Variable | Effect |
225
+ | --- | --- |
226
+ | `APPSTORE_MCP_READ_ONLY=true` | **Blocks every write** — only reads/reports/audits run |
227
+ | `APPSTORE_MCP_ALLOW_RELEASE=false` | Blocks `release_version` / `set_phased_release` |
228
+ | `APPSTORE_MCP_ALLOW_PRICE_CHANGES=false` | Blocks `set_app_price` |
229
+ | `APPSTORE_MCP_ALLOW_REVIEW_REPLIES=false` | Blocks posting public review replies |
230
+ | `APPSTORE_MCP_ALLOW_EXTERNAL_TESTFLIGHT=false` | Blocks `submit_beta_review` |
231
+
232
+ Blocked calls return a clear error explaining which flag to change. Run the
233
+ **`doctor`** tool to see the active mode and verify your whole setup.
234
+
219
235
  > **Reports need a higher-privilege key.** The sales/subscription/finance/analytics
220
236
  > tools require an API key with the **Admin, Finance, or Sales** role — an **App
221
237
  > Manager** key (fine for metadata/keywords/screenshots) returns `403` for reports.
@@ -245,6 +261,8 @@ Full parameter reference: **[docs/TOOLS.md](docs/TOOLS.md)**.
245
261
  | `audit_apps` | 🩺 **Fleet ASO audit** — scan all apps for missing subtitle/keywords/description, under-used keyword field, single-locale listings, missing screenshots. Read-only |
246
262
  | `apps_review_status` | 🗂️ **Fleet review board** — every app's current version + state (waiting / in-review / rejected / ready) in one call |
247
263
  | `submit_for_review` / `release_version` / `set_phased_release` | 🚀 Submit a version to Apple review (full flow), release an approved build, and control phased rollout |
264
+ | `doctor` | 🩺 Diagnose setup: Node, creds, key works, role capabilities, vendor number, Mac build tools, write mode |
265
+ | `snapshot_app_metadata` / `diff_app_metadata_snapshot` / `restore_app_metadata` | 💾 Save / compare / restore an app's text metadata — reversible ASO edits |
248
266
  | `release_readiness_check` | ✅ One-call **go/no-go report** — build, metadata, ASO, screenshots, compliance, TestFlight, reviews |
249
267
  | `aso_opportunity_report` / `portfolio_growth_report` | 📈 Rank the easiest **ASO wins** across all apps; portfolio snapshot of units sold per app |
250
268
  | `add_build_to_beta_group` / `submit_beta_review` | ✈️ Assign a build to a TestFlight group; submit for beta review |
package/docs/RECIPES.md CHANGED
@@ -35,6 +35,21 @@ blockers first.
35
35
 
36
36
  Uses: `release_readiness_check` (single call), or the individual tools.
37
37
 
38
+ <details><summary>Example output (fake data)</summary>
39
+
40
+ | Area | Status | Detail |
41
+ | --- | --- | --- |
42
+ | Build | pass | latest v42 — VALID |
43
+ | Description | pass | present |
44
+ | Keywords | warn | 61/100 chars |
45
+ | Screenshots | fail | none on the 6.7″ set |
46
+ | Subtitle | warn | missing (free ASO keywords) |
47
+ | Privacy policy | pass | set |
48
+ | Reviews | warn | 3 recent 1–2★ reviews |
49
+
50
+ **Verdict:** *Not ready* — 1 blocker (screenshots). 3 warnings worth fixing.
51
+ </details>
52
+
38
53
  ## 3. Release train — with approval gates
39
54
 
40
55
  ```text
@@ -104,6 +119,17 @@ trick — extra locales add searchable keywords). Dry-run a bulk localization up
104
119
  across those locales and show me the diff first.
105
120
  ```
106
121
 
122
+ **d) From an ASO-tool CSV export** (Appfigures / Sensor Tower / AppTweak / etc.)
123
+ ```text
124
+ Here's a keyword CSV export [paste or attach]. Pick the highest-volume, on-topic
125
+ terms that fit AppName's 100-char keyword field without duplicating the title or
126
+ subtitle, and dry-run the new keyword field for my approval.
127
+ ```
128
+
129
+ > The App Store Connect API has **no** competitor/keyword-volume data — bring your
130
+ > own (web search, public App Store pages, or an ASO tool export). This MCP keeps
131
+ > the *apply* side safe (read current → dry-run → write on approval).
132
+
107
133
  Uses: `get_app_store_version_localization`, `list_app_info_localizations`,
108
134
  `update_app_store_version_localization` (with `dryRun`),
109
135
  `bulk_update_version_localizations`, `aso_opportunity_report`.
package/docs/SECURITY.md CHANGED
@@ -10,6 +10,19 @@ Depending on the role you assign, the key can edit metadata and keywords, upload
10
10
  screenshots, manage TestFlight, change pricing, respond to reviews, and download
11
11
  sales/finance data. Scope it down.
12
12
 
13
+ ## Safe mode (tool-level write guardrails)
14
+
15
+ Beyond least-privilege keys, the server can **refuse** writes regardless of what
16
+ an agent tries. Set env vars to enforce it:
17
+
18
+ - `APPSTORE_MCP_READ_ONLY=true` — blocks every write (great for demos, audits, CI).
19
+ - `APPSTORE_MCP_ALLOW_RELEASE=false`, `..._ALLOW_PRICE_CHANGES=false`,
20
+ `..._ALLOW_REVIEW_REPLIES=false`, `..._ALLOW_EXTERNAL_TESTFLIGHT=false` — block
21
+ individual high-impact categories.
22
+
23
+ Blocked calls return a clear error. This turns "the agent should ask" into "the
24
+ tool cannot do it." Run `doctor` to see the active mode.
25
+
13
26
  ## Principle of least privilege
14
27
 
15
28
  - Create a **dedicated key** just for this server — don't reuse one.
package/docs/TOOLS.md CHANGED
@@ -389,6 +389,40 @@ Lists certificates and provisioning profiles, flagging expired/expiring (within
389
389
  ### list_game_center_leaderboards / list_game_center_achievements
390
390
  - `appId` **(required)** — requires Game Center enabled on the app.
391
391
 
392
+ ## Diagnostics & snapshots
393
+
394
+ ### doctor
395
+ No args. Read-only setup check: Node version, credentials, whether the API key
396
+ works, report-role capability, Vendor Number, Mac build tools, and the active
397
+ safe-mode write settings. Run this first when something isn't working.
398
+
399
+ ### snapshot_app_metadata
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.
404
+
405
+ ### diff_app_metadata_snapshot
406
+ - `appId` **(required)**, `snapshotFile` **(required)** — current vs snapshot diff.
407
+
408
+ ### restore_app_metadata
409
+ Restore text metadata from a snapshot (writes to the listing draft). Screenshots
410
+ not restored. `dryRun` to preview.
411
+ - `appId` **(required)**, `snapshotFile` **(required)**, `dryRun`
412
+
413
+ ## Safe mode (guardrails)
414
+
415
+ Set these env vars to enforce limits at the **server** (blocked calls return a
416
+ clear error):
417
+
418
+ | Variable | Effect |
419
+ | --- | --- |
420
+ | `APPSTORE_MCP_READ_ONLY=true` | block all writes |
421
+ | `APPSTORE_MCP_ALLOW_RELEASE=false` | block `release_version` / `set_phased_release` |
422
+ | `APPSTORE_MCP_ALLOW_PRICE_CHANGES=false` | block `set_app_price` |
423
+ | `APPSTORE_MCP_ALLOW_REVIEW_REPLIES=false` | block public review replies |
424
+ | `APPSTORE_MCP_ALLOW_EXTERNAL_TESTFLIGHT=false` | block `submit_beta_review` |
425
+
392
426
  ## Recipes & aggregators (read-only)
393
427
 
394
428
  ### release_readiness_check
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appstore-api-mcp",
3
- "version": "1.9.1",
3
+ "version": "1.10.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": {
@@ -16,7 +16,8 @@
16
16
  ".env.example"
17
17
  ],
18
18
  "scripts": {
19
- "start": "node src/index.js"
19
+ "start": "node src/index.js",
20
+ "test": "node --test test/*.test.js"
20
21
  },
21
22
  "keywords": [
22
23
  "mcp",
@@ -0,0 +1,99 @@
1
+ // Safe-mode guardrails: tool-level enforcement of read-only / category blocks.
2
+ // Extracted so it can be unit-tested without a live server.
3
+
4
+ // Tools that change App Store Connect state OR the user's local project.
5
+ export const WRITE_TOOLS = new Set([
6
+ // listing metadata
7
+ "update_app_info_localization",
8
+ "create_app_info_localization",
9
+ "create_app_store_version",
10
+ "update_app_store_version_localization",
11
+ "create_app_store_version_localization",
12
+ "bulk_update_version_localizations",
13
+ // screenshots
14
+ "create_screenshot_set",
15
+ "upload_screenshot",
16
+ "delete_screenshot",
17
+ // reviews
18
+ "reply_to_customer_review",
19
+ // testflight
20
+ "add_beta_tester",
21
+ "add_build_to_beta_group",
22
+ "submit_beta_review",
23
+ // catalog / pricing
24
+ "update_in_app_purchase",
25
+ "set_app_price",
26
+ // provisioning
27
+ "register_bundle_id",
28
+ "register_device",
29
+ "create_certificate",
30
+ "revoke_certificate",
31
+ "create_profile",
32
+ "delete_profile",
33
+ // submission / release
34
+ "submit_for_review",
35
+ "release_version",
36
+ "set_phased_release",
37
+ // build & ship (modify project / upload)
38
+ "bump_build_number",
39
+ "upload_build",
40
+ // snapshots
41
+ "restore_app_metadata",
42
+ ]);
43
+
44
+ // High-impact categories with their own opt-out env flags.
45
+ export const CATEGORY_TOOLS = {
46
+ RELEASE: new Set(["release_version", "set_phased_release"]),
47
+ PRICE_CHANGES: new Set(["set_app_price"]),
48
+ REVIEW_REPLIES: new Set(["reply_to_customer_review"]),
49
+ EXTERNAL_TESTFLIGHT: new Set(["submit_beta_review"]),
50
+ };
51
+
52
+ /** Parse an env var as a boolean, with a default when unset/blank. */
53
+ export function envBool(value, dflt = false) {
54
+ if (value === undefined || value === null || value === "") return dflt;
55
+ return /^(1|true|yes|on)$/i.test(String(value).trim());
56
+ }
57
+
58
+ /** Is this tool call a write? raw_request is a write unless the method is GET. */
59
+ export function isWriteTool(name, args = {}) {
60
+ if (name === "raw_request") {
61
+ const m = (args.method || "GET").toUpperCase();
62
+ return m !== "GET";
63
+ }
64
+ return WRITE_TOOLS.has(name);
65
+ }
66
+
67
+ /**
68
+ * Return a human-readable reason this tool call is blocked by the current env,
69
+ * or null if it's allowed. `env` defaults to process.env.
70
+ */
71
+ export function writeBlockReason(name, args = {}, env = process.env) {
72
+ if (!isWriteTool(name, args)) return null;
73
+ if (envBool(env.APPSTORE_MCP_READ_ONLY)) {
74
+ return "Blocked: the server is in READ-ONLY mode (APPSTORE_MCP_READ_ONLY=true). Unset it to allow writes.";
75
+ }
76
+ const gates = [
77
+ ["RELEASE", "APPSTORE_MCP_ALLOW_RELEASE", "releasing a version"],
78
+ ["PRICE_CHANGES", "APPSTORE_MCP_ALLOW_PRICE_CHANGES", "changing prices"],
79
+ ["REVIEW_REPLIES", "APPSTORE_MCP_ALLOW_REVIEW_REPLIES", "posting public review replies"],
80
+ ["EXTERNAL_TESTFLIGHT", "APPSTORE_MCP_ALLOW_EXTERNAL_TESTFLIGHT", "submitting external TestFlight review"],
81
+ ];
82
+ for (const [cat, flag, label] of gates) {
83
+ if (CATEGORY_TOOLS[cat].has(name) && envBool(env[flag], true) === false) {
84
+ return `Blocked: ${label} is disabled (${flag}=false). Set ${flag}=true to allow it.`;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /** Summarize the current write-mode for the doctor tool. */
91
+ export function writeModeSummary(env = process.env) {
92
+ return {
93
+ readOnly: envBool(env.APPSTORE_MCP_READ_ONLY),
94
+ allowRelease: envBool(env.APPSTORE_MCP_ALLOW_RELEASE, true),
95
+ allowPriceChanges: envBool(env.APPSTORE_MCP_ALLOW_PRICE_CHANGES, true),
96
+ allowReviewReplies: envBool(env.APPSTORE_MCP_ALLOW_REVIEW_REPLIES, true),
97
+ allowExternalTestflight: envBool(env.APPSTORE_MCP_ALLOW_EXTERNAL_TESTFLIGHT, true),
98
+ };
99
+ }
package/src/index.js CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  ListToolsRequestSchema,
18
18
  } from "@modelcontextprotocol/sdk/types.js";
19
19
  import { AppStoreConnectClient } from "./client.js";
20
+ import { LIMITS, validateAttributes, buildDiff } from "./validation.js";
21
+ import { writeBlockReason, writeModeSummary } from "./guardrails.js";
20
22
 
21
23
  const client = new AppStoreConnectClient({
22
24
  keyId: process.env.ASC_KEY_ID,
@@ -35,57 +37,7 @@ const fail = (e) => ({
35
37
  });
36
38
 
37
39
  // ---- Shared helpers (validation, diff, concurrency) -------------------------
38
-
39
- // Apple's documented length limits for editable metadata fields.
40
- const LIMITS = {
41
- name: 30,
42
- subtitle: 30,
43
- keywords: 100,
44
- promotionalText: 170,
45
- description: 4000,
46
- whatsNew: 4000,
47
- };
48
-
49
- /** Warn about fields that exceed Apple's limits. Non-blocking. */
50
- function validateAttributes(attributes) {
51
- const warnings = [];
52
- for (const [field, value] of Object.entries(attributes)) {
53
- const limit = LIMITS[field];
54
- if (limit && typeof value === "string" && value.length > limit) {
55
- warnings.push(
56
- `'${field}' is ${value.length} chars — exceeds Apple's limit of ${limit}.`,
57
- );
58
- }
59
- }
60
- return warnings;
61
- }
62
-
63
- /**
64
- * Build a field-by-field diff between current attributes and proposed changes,
65
- * including length/limit info. Used by dry-run mode.
66
- */
67
- function buildDiff(current = {}, attributes) {
68
- const changes = [];
69
- for (const [field, to] of Object.entries(attributes)) {
70
- const from = current[field] ?? null;
71
- const limit = LIMITS[field];
72
- changes.push({
73
- field,
74
- from,
75
- to,
76
- changed: from !== to,
77
- ...(limit
78
- ? {
79
- newLength: typeof to === "string" ? to.length : null,
80
- limit,
81
- exceedsLimit:
82
- typeof to === "string" ? to.length > limit : false,
83
- }
84
- : {}),
85
- });
86
- }
87
- return changes;
88
- }
40
+ // LIMITS / validateAttributes / buildDiff live in ./validation.js (unit-tested).
89
41
 
90
42
  /**
91
43
  * Dry-run vs apply for an update. When dryRun is true, fetch current values,
@@ -195,6 +147,69 @@ function ensureAltoolKey(keyId) {
195
147
  return true;
196
148
  }
197
149
 
150
+ // ---- Snapshot helpers ----
151
+
152
+ const SNAPSHOT_DIR =
153
+ process.env.APPSTORE_MCP_SNAPSHOT_DIR ||
154
+ join(homedir(), ".appstore-api-mcp", "snapshots");
155
+
156
+ const APP_INFO_FIELDS = ["name", "subtitle", "privacyPolicyUrl", "privacyPolicyText"];
157
+ const VERSION_LOC_FIELDS = [
158
+ "description",
159
+ "keywords",
160
+ "promotionalText",
161
+ "whatsNew",
162
+ "marketingUrl",
163
+ "supportUrl",
164
+ ];
165
+
166
+ /** Collect an app's editable TEXT metadata (the snapshot/diff/restore payload). */
167
+ async function collectAppMetadata(appId) {
168
+ const app = await client.get(`/apps/${appId}`);
169
+ const snap = {
170
+ appId,
171
+ name: app.data.attributes.name,
172
+ bundleId: app.data.attributes.bundleId,
173
+ capturedAt: new Date().toISOString(),
174
+ appInfo: null,
175
+ version: null,
176
+ screenshots: [],
177
+ };
178
+ const infos = await client.getAll(`/apps/${appId}/appInfos`);
179
+ if (infos.length) {
180
+ const il = await client.getAll(`/appInfos/${infos[0].id}/appInfoLocalizations`);
181
+ snap.appInfo = { id: infos[0].id, localizations: {} };
182
+ for (const l of il) {
183
+ const o = {};
184
+ for (const f of APP_INFO_FIELDS) o[f] = l.attributes[f] ?? null;
185
+ snap.appInfo.localizations[l.attributes.locale] = { id: l.id, ...o };
186
+ }
187
+ }
188
+ const versions = await client.getAll(`/apps/${appId}/appStoreVersions`, { limit: 5 });
189
+ const ed = versions.find((v) => EDITABLE_VERSION_STATES.has(v.attributes.appStoreState)) || versions[0];
190
+ if (ed) {
191
+ const locs = await client.getAll(`/appStoreVersions/${ed.id}/appStoreVersionLocalizations`);
192
+ snap.version = { id: ed.id, versionString: ed.attributes.versionString, localizations: {} };
193
+ for (const l of locs) {
194
+ const o = {};
195
+ for (const f of VERSION_LOC_FIELDS) o[f] = l.attributes[f] ?? null;
196
+ snap.version.localizations[l.attributes.locale] = { id: l.id, ...o };
197
+ // screenshot references (not the pixels)
198
+ const sets = await client.getAll(`/appStoreVersionLocalizations/${l.id}/appScreenshotSets`);
199
+ for (const s of sets) {
200
+ 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
+ });
207
+ }
208
+ }
209
+ }
210
+ return snap;
211
+ }
212
+
198
213
  /** Cap parsed report rows so large reports don't flood the response. */
199
214
  function reportResult(reportType, parsed, limit = 200) {
200
215
  const rows = parsed.rows;
@@ -2065,6 +2080,180 @@ const tools = [
2065
2080
  },
2066
2081
  },
2067
2082
 
2083
+ // ---- Diagnostics & snapshots ----
2084
+ {
2085
+ name: "doctor",
2086
+ description:
2087
+ "Diagnose the setup: Node version, credentials present, whether the API key works, role capabilities (metadata vs reports), Vendor Number, Mac build tools (Xcode/altool/agvtool), and the current safe-mode write settings. Read-only. Run this first if something isn't working.",
2088
+ inputSchema: { type: "object", properties: {} },
2089
+ run: async () => {
2090
+ const checks = [];
2091
+ const add = (name, status, detail) => checks.push({ name, status, detail });
2092
+ // Node
2093
+ const major = parseInt(process.versions.node.split(".")[0], 10);
2094
+ add("Node.js", major >= 18 ? "pass" : "fail", `${process.version} (need ≥ 18)`);
2095
+ // Credentials present
2096
+ add("ASC_KEY_ID", process.env.ASC_KEY_ID ? "pass" : "fail", process.env.ASC_KEY_ID ? "set" : "missing");
2097
+ add("ASC_ISSUER_ID", process.env.ASC_ISSUER_ID ? "pass" : "fail", process.env.ASC_ISSUER_ID ? "set" : "missing");
2098
+ const keySrc = process.env.ASC_PRIVATE_KEY_PATH || process.env.ASC_PRIVATE_KEY || process.env.ASC_PRIVATE_KEY_BASE64;
2099
+ add("Private key", keySrc ? "pass" : "fail", process.env.ASC_PRIVATE_KEY_PATH ? `path: ${process.env.ASC_PRIVATE_KEY_PATH}` : keySrc ? "inline/base64" : "missing");
2100
+ // API key works (list 1 app)
2101
+ try {
2102
+ const apps = await client.getAll("/apps", { limit: 1 });
2103
+ add("API key works", "pass", apps.length ? `e.g. ${apps[0].attributes.name}` : "authenticated (no apps)");
2104
+ } catch (e) {
2105
+ add("API key works", "fail", e.message.slice(0, 100));
2106
+ }
2107
+ // Report role probe: a real (tiny) sales report — 403 = key lacks the
2108
+ // Admin/Finance/Sales role; success (data or empty) = it works.
2109
+ if (process.env.ASC_VENDOR_NUMBER) {
2110
+ const probeDate = new Date(Date.now() - 3 * 86400000).toISOString().slice(0, 10);
2111
+ try {
2112
+ await client.getReport("/salesReports", {
2113
+ "filter[vendorNumber]": process.env.ASC_VENDOR_NUMBER,
2114
+ "filter[frequency]": "DAILY",
2115
+ "filter[reportType]": "SALES",
2116
+ "filter[reportSubType]": "SUMMARY",
2117
+ "filter[reportDate]": probeDate,
2118
+ "filter[version]": "1_1",
2119
+ });
2120
+ add("Report/analytics role", "pass", "key can read sales/finance reports");
2121
+ } catch (e) {
2122
+ add("Report/analytics role", e.status === 403 ? "warn" : "info", e.status === 403 ? "key lacks Admin/Finance/Sales role (metadata still works)" : `probe inconclusive (${e.status || "?"})`);
2123
+ }
2124
+ } else {
2125
+ add("Report/analytics role", "info", "set ASC_VENDOR_NUMBER to verify report access");
2126
+ }
2127
+ // Vendor number
2128
+ add("Vendor number", process.env.ASC_VENDOR_NUMBER ? "pass" : "info", process.env.ASC_VENDOR_NUMBER ? "set" : "not set (needed for sales/finance)");
2129
+ // Mac build tools
2130
+ if (process.platform === "darwin") {
2131
+ const sel = await runCmd("xcode-select", ["-p"]);
2132
+ add("Xcode", sel.code === 0 ? "pass" : "warn", sel.code === 0 ? sel.stdout.trim() : "not found (needed only for build & ship)");
2133
+ const at = await runCmd("xcrun", ["--find", "altool"]);
2134
+ add("altool", at.code === 0 ? "pass" : "warn", at.code === 0 ? "available" : "not found");
2135
+ } else {
2136
+ add("Build tools", "info", `not on macOS (${process.platform}) — build & ship tools unavailable`);
2137
+ }
2138
+ // Write mode
2139
+ const mode = writeModeSummary();
2140
+ add("Write mode", mode.readOnly ? "info" : "pass", mode.readOnly ? "READ-ONLY (writes blocked)" : "writes allowed");
2141
+ return {
2142
+ summary: {
2143
+ pass: checks.filter((c) => c.status === "pass").length,
2144
+ warn: checks.filter((c) => c.status === "warn").length,
2145
+ fail: checks.filter((c) => c.status === "fail").length,
2146
+ },
2147
+ safeMode: mode,
2148
+ checks,
2149
+ };
2150
+ },
2151
+ },
2152
+ {
2153
+ name: "snapshot_app_metadata",
2154
+ 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.",
2156
+ inputSchema: {
2157
+ type: "object",
2158
+ properties: {
2159
+ appId: { type: "string" },
2160
+ label: { type: "string", description: "Optional label added to the filename" },
2161
+ },
2162
+ required: ["appId"],
2163
+ },
2164
+ run: async (a) => {
2165
+ const snap = await collectAppMetadata(a.appId);
2166
+ 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`);
2170
+ writeFileSync(file, JSON.stringify(snap, null, 2));
2171
+ return {
2172
+ file,
2173
+ app: snap.name,
2174
+ locales: {
2175
+ appInfo: snap.appInfo ? Object.keys(snap.appInfo.localizations).length : 0,
2176
+ version: snap.version ? Object.keys(snap.version.localizations).length : 0,
2177
+ },
2178
+ screenshotSets: snap.screenshots.length,
2179
+ note: "Text metadata + screenshot references saved. Screenshot images themselves are not stored.",
2180
+ };
2181
+ },
2182
+ },
2183
+ {
2184
+ name: "diff_app_metadata_snapshot",
2185
+ description:
2186
+ "Compare an app's CURRENT App Store metadata against a saved snapshot file (from snapshot_app_metadata). Shows what changed per locale/field. Read-only.",
2187
+ inputSchema: {
2188
+ type: "object",
2189
+ properties: {
2190
+ appId: { type: "string" },
2191
+ snapshotFile: { type: "string", description: "Path returned by snapshot_app_metadata" },
2192
+ },
2193
+ required: ["appId", "snapshotFile"],
2194
+ },
2195
+ run: async (a) => {
2196
+ if (!existsSync(a.snapshotFile)) return { error: `Snapshot not found: ${a.snapshotFile}` };
2197
+ const saved = JSON.parse(readFileSync(a.snapshotFile, "utf8"));
2198
+ const current = await collectAppMetadata(a.appId);
2199
+ const diffs = [];
2200
+ const cmp = (scope, fields, savedLocs, curLocs) => {
2201
+ for (const [locale, sv] of Object.entries(savedLocs || {})) {
2202
+ const cv = (curLocs || {})[locale] || {};
2203
+ for (const f of fields)
2204
+ if ((sv[f] ?? null) !== (cv[f] ?? null))
2205
+ diffs.push({ scope, locale, field: f, snapshot: sv[f] ?? null, current: cv[f] ?? null });
2206
+ }
2207
+ };
2208
+ cmp("appInfo", APP_INFO_FIELDS, saved.appInfo?.localizations, current.appInfo?.localizations);
2209
+ cmp("version", VERSION_LOC_FIELDS, saved.version?.localizations, current.version?.localizations);
2210
+ return { app: current.name, snapshotCapturedAt: saved.capturedAt, changedFields: diffs.length, diffs };
2211
+ },
2212
+ },
2213
+ {
2214
+ name: "restore_app_metadata",
2215
+ description:
2216
+ "Restore an app's editable TEXT metadata from a saved snapshot (name/subtitle/privacy + description/keywords/etc., per locale). Screenshots are NOT restored. Set dryRun:true to preview. This WRITES to the live listing draft — confirm with the user first.",
2217
+ inputSchema: {
2218
+ type: "object",
2219
+ properties: {
2220
+ appId: { type: "string" },
2221
+ snapshotFile: { type: "string" },
2222
+ dryRun: { type: "boolean" },
2223
+ },
2224
+ required: ["appId", "snapshotFile"],
2225
+ },
2226
+ run: async (a) => {
2227
+ if (!existsSync(a.snapshotFile)) return { error: `Snapshot not found: ${a.snapshotFile}` };
2228
+ const saved = JSON.parse(readFileSync(a.snapshotFile, "utf8"));
2229
+ const current = await collectAppMetadata(a.appId);
2230
+ const actions = [];
2231
+ // app info localizations
2232
+ for (const [locale, sv] of Object.entries(saved.appInfo?.localizations || {})) {
2233
+ const cur = current.appInfo?.localizations?.[locale];
2234
+ if (!cur) { actions.push({ scope: "appInfo", locale, skipped: "locale no longer present" }); continue; }
2235
+ const attributes = {};
2236
+ for (const f of APP_INFO_FIELDS) if ((sv[f] ?? null) !== (cur[f] ?? null) && sv[f] != null) attributes[f] = sv[f];
2237
+ if (Object.keys(attributes).length) {
2238
+ if (!a.dryRun) await client.patch(`/appInfoLocalizations/${cur.id}`, { data: { type: "appInfoLocalizations", id: cur.id, attributes } });
2239
+ actions.push({ scope: "appInfo", locale, fields: Object.keys(attributes) });
2240
+ }
2241
+ }
2242
+ // version localizations
2243
+ for (const [locale, sv] of Object.entries(saved.version?.localizations || {})) {
2244
+ const cur = current.version?.localizations?.[locale];
2245
+ if (!cur) { actions.push({ scope: "version", locale, skipped: "locale no longer present" }); continue; }
2246
+ const attributes = {};
2247
+ for (const f of VERSION_LOC_FIELDS) if ((sv[f] ?? null) !== (cur[f] ?? null) && sv[f] != null) attributes[f] = sv[f];
2248
+ if (Object.keys(attributes).length) {
2249
+ if (!a.dryRun) await client.patch(`/appStoreVersionLocalizations/${cur.id}`, { data: { type: "appStoreVersionLocalizations", id: cur.id, attributes } });
2250
+ actions.push({ scope: "version", locale, fields: Object.keys(attributes) });
2251
+ }
2252
+ }
2253
+ return { dryRun: !!a.dryRun, app: current.name, restored: actions.length, actions };
2254
+ },
2255
+ },
2256
+
2068
2257
  // ---- Recipes & aggregators (read-only) ----
2069
2258
  {
2070
2259
  name: "release_readiness_check",
@@ -2447,7 +2636,7 @@ Some things a great App Store listing needs live OUTSIDE App Store Connect and c
2447
2636
  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.`;
2448
2637
 
2449
2638
  const server = new Server(
2450
- { name: "appstore-api-mcp", version: "1.9.1" },
2639
+ { name: "appstore-api-mcp", version: "1.10.0" },
2451
2640
  { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS },
2452
2641
  );
2453
2642
 
@@ -2462,6 +2651,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2462
2651
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
2463
2652
  const tool = toolMap[req.params.name];
2464
2653
  if (!tool) return fail(new Error(`Unknown tool: ${req.params.name}`));
2654
+ // Safe-mode guardrails: block writes that the environment disallows.
2655
+ const blocked = writeBlockReason(req.params.name, req.params.arguments || {});
2656
+ if (blocked) return fail(new Error(blocked));
2465
2657
  try {
2466
2658
  const result = await tool.run(req.params.arguments || {});
2467
2659
  // Tools may return raw MCP content (e.g. images) via __mcpContent.
@@ -0,0 +1,52 @@
1
+ // Field validation + diffing for App Store Connect metadata.
2
+ // Extracted into its own module so it can be unit-tested.
3
+
4
+ // Apple's documented length limits for editable metadata fields.
5
+ export const LIMITS = {
6
+ name: 30,
7
+ subtitle: 30,
8
+ keywords: 100,
9
+ promotionalText: 170,
10
+ description: 4000,
11
+ whatsNew: 4000,
12
+ };
13
+
14
+ /** Warn about fields that exceed Apple's limits. Non-blocking. */
15
+ export function validateAttributes(attributes) {
16
+ const warnings = [];
17
+ for (const [field, value] of Object.entries(attributes || {})) {
18
+ const limit = LIMITS[field];
19
+ if (limit && typeof value === "string" && value.length > limit) {
20
+ warnings.push(
21
+ `'${field}' is ${value.length} chars — exceeds Apple's limit of ${limit}.`,
22
+ );
23
+ }
24
+ }
25
+ return warnings;
26
+ }
27
+
28
+ /**
29
+ * Build a field-by-field diff between current attributes and proposed changes,
30
+ * including length/limit info. Used by dry-run mode.
31
+ */
32
+ export function buildDiff(current = {}, attributes = {}) {
33
+ const changes = [];
34
+ for (const [field, to] of Object.entries(attributes)) {
35
+ const from = current[field] ?? null;
36
+ const limit = LIMITS[field];
37
+ changes.push({
38
+ field,
39
+ from,
40
+ to,
41
+ changed: from !== to,
42
+ ...(limit
43
+ ? {
44
+ newLength: typeof to === "string" ? to.length : null,
45
+ limit,
46
+ exceedsLimit: typeof to === "string" ? to.length > limit : false,
47
+ }
48
+ : {}),
49
+ });
50
+ }
51
+ return changes;
52
+ }