appstore-api-mcp 1.12.0 → 1.16.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/src/index.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  copyFileSync,
8
8
  readdirSync,
9
9
  } from "node:fs";
10
- import { basename, join } from "node:path";
10
+ import { basename, join, dirname } from "node:path";
11
11
  import { homedir, tmpdir } from "node:os";
12
12
  import { execFile } from "node:child_process";
13
13
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -19,6 +19,21 @@ import {
19
19
  import { AppStoreConnectClient } from "./client.js";
20
20
  import { LIMITS, validateAttributes, buildDiff } from "./validation.js";
21
21
  import { writeBlockReason, writeModeSummary } from "./guardrails.js";
22
+ import {
23
+ renderTemplates,
24
+ detectProject,
25
+ existingPipelineFiles,
26
+ parseRepoSlug,
27
+ } from "./cicd.js";
28
+ import {
29
+ loadCountries,
30
+ calculateTargetPrices,
31
+ findPricePoint,
32
+ decodeTerritory,
33
+ validateCoefficients,
34
+ tierSummary,
35
+ } from "./ppp.js";
36
+ import { detectOverlappingOffers } from "./subscriptions.js";
22
37
 
23
38
  const client = new AppStoreConnectClient({
24
39
  keyId: process.env.ASC_KEY_ID,
@@ -120,6 +135,746 @@ function runCmd(cmd, args, opts = {}) {
120
135
 
121
136
  const tail = (s, n = 40) => (s || "").split("\n").slice(-n).join("\n");
122
137
 
138
+ /**
139
+ * Like runCmd, but pipes `input` to the child's stdin and closes it. Used to
140
+ * pass secret values to `gh secret set` without exposing them in argv/ps.
141
+ */
142
+ function runCmdStdin(cmd, args, input, opts = {}) {
143
+ return new Promise((resolve) => {
144
+ const child = execFile(
145
+ cmd,
146
+ args,
147
+ {
148
+ cwd: opts.cwd,
149
+ timeout: opts.timeout || 0,
150
+ maxBuffer: 64 * 1024 * 1024,
151
+ env: process.env,
152
+ },
153
+ (err, stdout, stderr) => {
154
+ resolve({
155
+ code: err && typeof err.code === "number" ? err.code : err ? 1 : 0,
156
+ stdout: stdout || "",
157
+ stderr: stderr || "",
158
+ error: err ? err.message : null,
159
+ });
160
+ },
161
+ );
162
+ if (input != null && child.stdin) {
163
+ child.stdin.on("error", () => {});
164
+ child.stdin.write(input);
165
+ child.stdin.end();
166
+ }
167
+ });
168
+ }
169
+
170
+ // ---- iOS CI/CD → TestFlight bootstrap helpers ----
171
+
172
+ /** Ensure the `gh` CLI is installed and authenticated; throw friendly guidance. */
173
+ async function ensureGh() {
174
+ const v = await runCmd("gh", ["--version"]);
175
+ if (v.code !== 0)
176
+ throw new Error(
177
+ "GitHub CLI (`gh`) not found. Install it from https://cli.github.com (e.g. `brew install gh`), then `gh auth login`.",
178
+ );
179
+ const auth = await runCmd("gh", ["auth", "status"]);
180
+ if (auth.code !== 0)
181
+ throw new Error(
182
+ "GitHub CLI is not authenticated. Run `gh auth login` (needs repo admin to set Actions secrets).",
183
+ );
184
+ }
185
+
186
+ /** Resolve the GitHub owner/name slug for a tool call (explicit wins, else origin). */
187
+ async function resolveRepoSlug({ repo, owner, repoDir }) {
188
+ if (repo && repo.includes("/")) return repo;
189
+ if (repo && owner) return `${owner}/${repo}`;
190
+ const r = await runCmd("git", ["-C", repoDir || ".", "remote", "get-url", "origin"]);
191
+ const slug = r.code === 0 ? parseRepoSlug(r.stdout) : null;
192
+ if (repo && slug) return `${slug.split("/")[0]}/${repo}`; // repo name + origin owner
193
+ if (slug) return slug;
194
+ throw new Error(
195
+ "Could not determine the GitHub repo. Pass `repo` as \"owner/name\", or run from a clone with an `origin` remote.",
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Read the App Store Connect API key material from THIS server's own config
201
+ * (env), returning the three CI secret values. The .p8 is base64-encoded as
202
+ * GitHub Actions / fastlane (is_key_content_base64) expect. Never logged or
203
+ * returned to tool output — only handed to `gh secret set` over stdin.
204
+ */
205
+ function ascSecretValues() {
206
+ const keyId = process.env.ASC_KEY_ID;
207
+ const issuerId = process.env.ASC_ISSUER_ID;
208
+ if (!keyId || !issuerId)
209
+ throw new Error(
210
+ "ASC_KEY_ID / ASC_ISSUER_ID are not set on this server. Configure the App Store Connect API key in the server's environment first.",
211
+ );
212
+ // p8 → base64, mirroring client.js precedence: base64 env, inline PEM, path.
213
+ let p8b64;
214
+ if (process.env.ASC_PRIVATE_KEY_BASE64) {
215
+ p8b64 = process.env.ASC_PRIVATE_KEY_BASE64.replace(/\s+/g, "");
216
+ } else if (process.env.ASC_PRIVATE_KEY) {
217
+ p8b64 = Buffer.from(process.env.ASC_PRIVATE_KEY, "utf8").toString("base64");
218
+ } else if (process.env.ASC_PRIVATE_KEY_PATH) {
219
+ p8b64 = Buffer.from(
220
+ readFileSync(process.env.ASC_PRIVATE_KEY_PATH, "utf8"),
221
+ "utf8",
222
+ ).toString("base64");
223
+ } else {
224
+ throw new Error(
225
+ "No .p8 key configured. Set ASC_PRIVATE_KEY_PATH, ASC_PRIVATE_KEY, or ASC_PRIVATE_KEY_BASE64 on this server.",
226
+ );
227
+ }
228
+ return {
229
+ ASC_KEY_ID: keyId,
230
+ ASC_ISSUER_ID: issuerId,
231
+ ASC_KEY_P8: p8b64,
232
+ };
233
+ }
234
+
235
+ /** Find the App Store Connect app record for a bundle id (find-only). */
236
+ async function findAppByBundleId(bundleId) {
237
+ const apps = await client.getAll("/apps", {
238
+ "filter[bundleId]": bundleId,
239
+ limit: 200,
240
+ });
241
+ // filter[bundleId] is a prefix-ish match on some accounts — require exact.
242
+ return apps.find((a) => a.attributes?.bundleId === bundleId) || null;
243
+ }
244
+
245
+ /**
246
+ * Shared bootstrap implementation (used by bootstrap_ios_cicd and the
247
+ * bootstrap_testflight orchestrator). Renders the pipeline files, then commits
248
+ * them to a branch / opens a PR via git + gh. Returns a structured report.
249
+ */
250
+ async function doBootstrap(a) {
251
+ const repoDir = a.repoDir || ".";
252
+ if (!existsSync(repoDir))
253
+ return { error: `repoDir not found: ${repoDir}` };
254
+ const gitCheck = await runCmd("git", ["-C", repoDir, "rev-parse", "--show-toplevel"]);
255
+ if (gitCheck.code !== 0)
256
+ return { error: `${repoDir} is not a git repository.` };
257
+ const repoRoot = gitCheck.stdout.trim();
258
+
259
+ const { detected, missing, warnings } = detectProject(repoRoot, {
260
+ appDir: a.appDir,
261
+ bundleId: a.bundleId,
262
+ teamId: a.teamId,
263
+ scheme: a.scheme,
264
+ target: a.target,
265
+ });
266
+ if (missing.length)
267
+ return {
268
+ error: `Could not auto-detect ${missing.join(", ")}. Pass them explicitly.`,
269
+ detected,
270
+ warnings,
271
+ };
272
+
273
+ const files = renderTemplates(detected);
274
+ const existing = existingPipelineFiles(repoRoot, files);
275
+
276
+ if (a.dryRun) {
277
+ return {
278
+ dryRun: true,
279
+ detected,
280
+ warnings,
281
+ filesToWrite: Object.keys(files),
282
+ wouldOverwrite: existing,
283
+ files, // full rendered contents for review
284
+ note: "Nothing was written. Re-run without dryRun to apply.",
285
+ };
286
+ }
287
+
288
+ // Write files.
289
+ for (const [rel, contents] of Object.entries(files)) {
290
+ const abs = join(repoRoot, rel);
291
+ mkdirSync(dirname(abs), { recursive: true });
292
+ writeFileSync(abs, contents);
293
+ }
294
+
295
+ const mode = a.mode || "pr";
296
+ const written = Object.keys(files);
297
+ const result = { detected, warnings, filesWritten: written, overwrote: existing, mode };
298
+
299
+ if (mode === "files") {
300
+ result.note = "Files written to the working tree; not committed (mode: files).";
301
+ return result;
302
+ }
303
+
304
+ // Commit on a branch.
305
+ const branch = a.branch || "ci/ios-testflight-bootstrap";
306
+ const base =
307
+ a.baseBranch ||
308
+ (await runCmd("git", ["-C", repoRoot, "symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim() ||
309
+ "main";
310
+ const co = await runCmd("git", ["-C", repoRoot, "checkout", "-B", branch]);
311
+ if (co.code !== 0)
312
+ return { ...result, error: "git checkout failed", log: tail(co.stderr, 20) };
313
+ await runCmd("git", ["-C", repoRoot, "add", ...written]);
314
+ const commit = await runCmd("git", [
315
+ "-C", repoRoot, "commit", "-m", "ci: add fastlane + GitHub Actions TestFlight pipeline",
316
+ ]);
317
+ if (commit.code !== 0)
318
+ return {
319
+ ...result,
320
+ branch,
321
+ error: "git commit failed (nothing to commit, or git not configured).",
322
+ log: tail(commit.stdout + "\n" + commit.stderr, 20),
323
+ };
324
+ result.branch = branch;
325
+
326
+ if (mode === "commit") {
327
+ result.note = `Committed to branch ${branch}. Push and open a PR when ready.`;
328
+ return result;
329
+ }
330
+
331
+ // mode "branch" or "pr": push, and for "pr" open a PR.
332
+ await ensureGh();
333
+ const push = await runCmd("git", ["-C", repoRoot, "push", "-u", "origin", branch]);
334
+ if (push.code !== 0)
335
+ return { ...result, error: "git push failed", log: tail(push.stderr, 20) };
336
+
337
+ if (mode === "branch") {
338
+ result.note = `Pushed branch ${branch}. Open a PR when ready.`;
339
+ return result;
340
+ }
341
+
342
+ const pr = await runCmd("gh", [
343
+ "pr", "create",
344
+ "--repo", await resolveRepoSlug({ repo: a.repo, owner: a.owner, repoDir: repoRoot }),
345
+ "--head", branch,
346
+ "--base", base,
347
+ "--title", "Add iOS CI/CD → TestFlight pipeline",
348
+ "--body",
349
+ "Adds fastlane + two GitHub Actions workflows (simulator CI build + signed TestFlight upload via Xcode cloud signing).\n\nRequires the `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_P8` repo secrets — set them with `set_repo_ci_secrets` if not already present.",
350
+ ]);
351
+ if (pr.code !== 0)
352
+ return { ...result, error: "gh pr create failed", log: tail(pr.stdout + "\n" + pr.stderr, 20) };
353
+ result.prUrl = pr.stdout.trim();
354
+ result.note = `Opened PR: ${result.prUrl}`;
355
+ return result;
356
+ }
357
+
358
+ /** Shared secret-pushing implementation (used by tool + orchestrator). */
359
+ async function doSetSecrets(a) {
360
+ await ensureGh();
361
+ const slug = await resolveRepoSlug({
362
+ repo: a.repo,
363
+ owner: a.owner,
364
+ repoDir: a.repoDir || ".",
365
+ });
366
+ const secrets = ascSecretValues(); // throws if server creds are missing
367
+ const set = [];
368
+ for (const [name, value] of Object.entries(secrets)) {
369
+ const res = await runCmdStdin("gh", ["secret", "set", name, "--repo", slug], value);
370
+ if (res.code !== 0)
371
+ return {
372
+ repo: slug,
373
+ error: `Failed to set ${name}`,
374
+ log: tail(res.stderr, 20),
375
+ secretsSet: set,
376
+ };
377
+ set.push(name); // names only — values are never returned
378
+ }
379
+ return {
380
+ repo: slug,
381
+ secretsSet: set,
382
+ note: "Three Actions secrets set from this server's configured API key. Values were never exposed.",
383
+ };
384
+ }
385
+
386
+ // ---- PPP (purchasing-power-parity) pricing helpers ----
387
+ // These operate on IAP / subscription *price points* across territories — a
388
+ // different surface from set_app_price (which sets the paid-app price).
389
+
390
+ const PPP_TERRITORIES_PER_REQUEST = 8; // ~800 points/territory; 8 fit one 8000-row page
391
+
392
+ /** Fetch every purchasable product (non-subscription IAPs v2 + subscriptions). */
393
+ async function pppFetchProducts(appId) {
394
+ const products = [];
395
+ const iaps = await client.getAll(`/apps/${appId}/inAppPurchasesV2`, { limit: 200 });
396
+ for (const e of iaps) {
397
+ const t = e.attributes?.inAppPurchaseType || "";
398
+ if (t === "AUTOMATICALLY_RENEWABLE_SUBSCRIPTION") continue;
399
+ products.push({
400
+ id: e.id,
401
+ name: e.attributes?.name || "",
402
+ productId: e.attributes?.productId || "",
403
+ productType: t,
404
+ isSubscription: false,
405
+ });
406
+ }
407
+ const groups = await client.getAll(`/apps/${appId}/subscriptionGroups`);
408
+ for (const g of groups) {
409
+ const subs = await client.getAll(`/subscriptionGroups/${g.id}/subscriptions`, { limit: 200 });
410
+ for (const s of subs) {
411
+ products.push({
412
+ id: s.id,
413
+ name: s.attributes?.name || "",
414
+ productId: s.attributes?.productId || "",
415
+ productType: "AUTOMATICALLY_RENEWABLE_SUBSCRIPTION",
416
+ isSubscription: true,
417
+ });
418
+ }
419
+ }
420
+ return products;
421
+ }
422
+
423
+ /** Current US price for a product (null if none set). */
424
+ async function pppFetchUsPrice(product) {
425
+ if (product.isSubscription) {
426
+ const { data, included } = await client.getAllPages(`/subscriptions/${product.id}/prices`, {
427
+ include: "subscriptionPricePoint,territory",
428
+ "fields[subscriptionPricePoints]": "customerPrice",
429
+ limit: 200,
430
+ });
431
+ const ppMap = new Map(
432
+ included.filter((i) => i.type === "subscriptionPricePoints").map((i) => [i.id, i]),
433
+ );
434
+ for (const price of data) {
435
+ if (price.relationships?.territory?.data?.id !== "USA") continue;
436
+ const ppId = price.relationships?.subscriptionPricePoint?.data?.id;
437
+ const pp = ppMap.get(ppId);
438
+ if (pp) return Number(pp.attributes.customerPrice);
439
+ }
440
+ return null;
441
+ }
442
+ const { data, included } = await client.getAllPages(
443
+ `/inAppPurchasePriceSchedules/${product.id}/manualPrices`,
444
+ {
445
+ include: "inAppPurchasePricePoint",
446
+ "fields[inAppPurchasePricePoints]": "customerPrice",
447
+ },
448
+ );
449
+ const ppMap = new Map(
450
+ included.filter((i) => i.type === "inAppPurchasePricePoints").map((i) => [i.id, i]),
451
+ );
452
+ for (const price of data) {
453
+ if (price.attributes?.startDate != null) continue; // skip scheduled future prices
454
+ const ppId = price.relationships?.inAppPurchasePricePoint?.data?.id || "";
455
+ if (decodeTerritory(ppId) === "USA") {
456
+ const pp = ppMap.get(ppId);
457
+ if (pp) return Number(pp.attributes.customerPrice);
458
+ }
459
+ }
460
+ return null;
461
+ }
462
+
463
+ /** Parse raw price-point rows into { id, customerPrice, territory3 }, skipping bad ones. */
464
+ function pppParsePoints(rows) {
465
+ const out = [];
466
+ for (const pp of rows) {
467
+ const price = pp.attributes?.customerPrice;
468
+ const territory3 = decodeTerritory(pp.id);
469
+ if (price == null || !territory3) continue;
470
+ const n = Number(price);
471
+ if (Number.isFinite(n)) out.push({ id: pp.id, customerPrice: n, territory3 });
472
+ }
473
+ return out;
474
+ }
475
+
476
+ /** All local price points per territory (keyed by 3-letter code), each sorted asc. */
477
+ async function pppFetchTerritoryGrids(product, territories) {
478
+ const path = product.isSubscription
479
+ ? `/subscriptions/${product.id}/pricePoints`
480
+ : `/v2/inAppPurchases/${product.id}/pricePoints`;
481
+ const unique = [...new Set(territories)];
482
+ const batches = [];
483
+ for (let i = 0; i < unique.length; i += PPP_TERRITORIES_PER_REQUEST)
484
+ batches.push(unique.slice(i, i + PPP_TERRITORIES_PER_REQUEST));
485
+
486
+ const grids = {};
487
+ await mapLimit(batches, 5, async (batch) => {
488
+ try {
489
+ const rows = await client.getAll(path, {
490
+ "filter[territory]": batch.join(","),
491
+ limit: 8000,
492
+ });
493
+ for (const pt of pppParsePoints(rows)) {
494
+ (grids[pt.territory3] ||= []).push(pt);
495
+ }
496
+ } catch (e) {
497
+ /* a failed batch just yields no grid for those territories */
498
+ }
499
+ });
500
+ for (const pts of Object.values(grids)) pts.sort((a, b) => a.customerPrice - b.customerPrice);
501
+ return grids;
502
+ }
503
+
504
+ /** Apple's equalized price points for ALL territories, from a USD base point. */
505
+ async function pppFetchEqualizations(product, usdPointId) {
506
+ const path = product.isSubscription
507
+ ? `/subscriptionPricePoints/${usdPointId}/equalizations`
508
+ : `/inAppPurchasePricePoints/${usdPointId}/equalizations`;
509
+ const rows = await client.getAll(path, { limit: 200 });
510
+ const out = {};
511
+ for (const pt of pppParsePoints(rows)) out[pt.territory3] = pt;
512
+ return out;
513
+ }
514
+
515
+ /** Territory -> ISO currency, for display. */
516
+ async function pppFetchCurrencies() {
517
+ try {
518
+ const data = await client.getAll("/territories", { limit: 200 });
519
+ const out = {};
520
+ for (const t of data) out[t.id] = t.attributes?.currency || "";
521
+ return out;
522
+ } catch {
523
+ return {};
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Shared resolution used by both preview and apply. Fetches the product, US
529
+ * price, the USA base tier, equalizations and territory grids, then snaps each
530
+ * territory's coefficient-scaled target (in LOCAL currency) onto a real grid
531
+ * point. Returns everything needed to display or apply.
532
+ */
533
+ async function pppResolve(a) {
534
+ const appId = a.appId;
535
+ const products = await pppFetchProducts(appId);
536
+ const product = products.find((p) => p.productId === a.productId || p.id === a.productId);
537
+ if (!product)
538
+ return {
539
+ error: `Product '${a.productId}' not found for app ${appId}.`,
540
+ availableProducts: products.map((p) => ({
541
+ productId: p.productId,
542
+ type: p.isSubscription ? "SUB" : "IAP",
543
+ })),
544
+ };
545
+
546
+ if (!product.isSubscription && (a.preserveCurrentPrice || a.startDate))
547
+ return {
548
+ error: `preserveCurrentPrice / startDate apply only to subscriptions; '${product.productId}' is an IAP.`,
549
+ };
550
+
551
+ const usPrice =
552
+ a.usPrice != null ? Number(a.usPrice) : await pppFetchUsPrice(product);
553
+ if (usPrice == null || !Number.isFinite(usPrice) || usPrice <= 0)
554
+ return { error: `Could not determine a US price for '${product.productId}'. Pass usPrice to override.` };
555
+
556
+ let overrides;
557
+ try {
558
+ overrides = validateCoefficients(a.coefficients);
559
+ } catch (e) {
560
+ return { error: e.message };
561
+ }
562
+
563
+ const exclude = Array.isArray(a.exclude)
564
+ ? a.exclude
565
+ : typeof a.exclude === "string"
566
+ ? a.exclude.split(",")
567
+ : [];
568
+ const countries = loadCountries(exclude);
569
+ const targets = calculateTargetPrices(
570
+ [{ id: product.id, name: product.name, productId: product.productId, usPrice }],
571
+ countries,
572
+ overrides,
573
+ );
574
+
575
+ // USA base tier: the USA grid point nearest the US price.
576
+ const usdGrid = (await pppFetchTerritoryGrids(product, ["USA"]))["USA"] || [];
577
+ if (!usdGrid.length)
578
+ return { error: "No USD price points available for this product." };
579
+ const usTier = findPricePoint(usdGrid, usPrice, usPrice);
580
+ if (!usTier) return { error: "No matching Apple price tier for the US price." };
581
+
582
+ const baselines = await pppFetchEqualizations(product, usTier.id);
583
+ if (!Object.keys(baselines).length)
584
+ return { error: "Could not load Apple's territory prices (equalizations) for the US price." };
585
+
586
+ const codes = targets.map((t) => t.countryCode);
587
+ const grids = await pppFetchTerritoryGrids(product, codes);
588
+
589
+ const resolved = {}; // territory3 -> { pointId, customerPrice }
590
+ const rows = [];
591
+ for (const t of targets) {
592
+ const base = baselines[t.countryCode];
593
+ const grid = grids[t.countryCode];
594
+ if (!base || !grid) {
595
+ rows.push({ ...t, skipped: "no local grid/baseline" });
596
+ continue;
597
+ }
598
+ const localTarget = base.customerPrice * t.coefficient;
599
+ const point = findPricePoint(grid, localTarget, base.customerPrice);
600
+ if (!point) {
601
+ rows.push({ ...t, skipped: "no grid point" });
602
+ continue;
603
+ }
604
+ resolved[t.countryCode] = point;
605
+ rows.push({
606
+ territory: t.countryCode,
607
+ country: t.countryName,
608
+ category: t.category,
609
+ coefficient: Math.round(t.coefficient * 1000) / 1000,
610
+ appleDefaultLocal: base.customerPrice,
611
+ chosenLocal: point.customerPrice,
612
+ pricePointId: point.id,
613
+ });
614
+ }
615
+ // Include USA itself (its own tier) so apply sets/keeps the base price too.
616
+ resolved.USA = usTier;
617
+
618
+ return { product, usPrice, usTier, resolved, rows, baselines, overrides };
619
+ }
620
+
621
+ /** Set all territory prices for an IAP in a single atomic price-schedule POST. */
622
+ async function pppApplyIapPrices(iapId, resolved) {
623
+ const refs = [];
624
+ const included = [];
625
+ let i = 0;
626
+ for (const point of Object.values(resolved)) {
627
+ const tempId = `\${price${i++}}`;
628
+ refs.push({ type: "inAppPurchasePrices", id: tempId });
629
+ included.push({
630
+ type: "inAppPurchasePrices",
631
+ id: tempId,
632
+ attributes: { startDate: null },
633
+ relationships: {
634
+ inAppPurchaseV2: { data: { type: "inAppPurchases", id: iapId } },
635
+ inAppPurchasePricePoint: {
636
+ data: { type: "inAppPurchasePricePoints", id: point.id },
637
+ },
638
+ },
639
+ });
640
+ }
641
+ return client.post("/inAppPurchasePriceSchedules", {
642
+ data: {
643
+ type: "inAppPurchasePriceSchedules",
644
+ relationships: {
645
+ inAppPurchase: { data: { type: "inAppPurchases", id: iapId } },
646
+ baseTerritory: { data: { type: "territories", id: "USA" } },
647
+ manualPrices: { data: refs },
648
+ },
649
+ },
650
+ included,
651
+ });
652
+ }
653
+
654
+ /** Delete pending (future-dated) subscription prices to avoid 409 conflicts. */
655
+ async function pppDeletePendingSubPrices(subId) {
656
+ const today = new Date().toISOString().slice(0, 10);
657
+ const data = await client.getAll(`/subscriptions/${subId}/prices`, { limit: 200 });
658
+ let deleted = 0;
659
+ for (const price of data) {
660
+ const start = price.attributes?.startDate;
661
+ if (start && start >= today) {
662
+ try {
663
+ await client.delete(`/subscriptionPrices/${price.id}`);
664
+ deleted++;
665
+ } catch {
666
+ /* best-effort */
667
+ }
668
+ }
669
+ }
670
+ return deleted;
671
+ }
672
+
673
+ /** Set one subscription territory price. */
674
+ function pppSetSubPrice(subId, territory3, point, preserved, startIso) {
675
+ return client.post("/subscriptionPrices", {
676
+ data: {
677
+ type: "subscriptionPrices",
678
+ attributes: { preserveCurrentPrice: !!preserved, startDate: startIso },
679
+ relationships: {
680
+ subscription: { data: { type: "subscriptions", id: subId } },
681
+ subscriptionPricePoint: {
682
+ data: { type: "subscriptionPricePoints", id: point.id },
683
+ },
684
+ territory: { data: { type: "territories", id: territory3 } },
685
+ },
686
+ },
687
+ });
688
+ }
689
+
690
+ /** Apply resolved prices. IAP = one request; subscription = per-territory. */
691
+ async function pppApply(product, resolved, { preserved, startDate } = {}) {
692
+ if (!product.isSubscription) {
693
+ await pppApplyIapPrices(product.id, resolved);
694
+ return { applied: Object.keys(resolved).length, failed: 0, territories: Object.keys(resolved).length };
695
+ }
696
+ // Subscriptions: default start 2 days out; clear pending first.
697
+ const startIso =
698
+ startDate || new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
699
+ const deletedPending = await pppDeletePendingSubPrices(product.id);
700
+ const entries = Object.entries(resolved);
701
+ let applied = 0;
702
+ const failures = [];
703
+ await mapLimit(entries, 10, async ([territory, point]) => {
704
+ try {
705
+ await pppSetSubPrice(product.id, territory, point, preserved, startIso);
706
+ applied++;
707
+ } catch (e) {
708
+ failures.push({ territory, error: e.message });
709
+ }
710
+ });
711
+ return {
712
+ applied,
713
+ failed: failures.length,
714
+ territories: entries.length,
715
+ startDate: startIso,
716
+ preserveCurrentPrice: !!preserved,
717
+ deletedPending,
718
+ failures: failures.slice(0, 10),
719
+ };
720
+ }
721
+
722
+ // ---- Build / submission helpers ----
723
+
724
+ /**
725
+ * Resolve a build to { id, attributes }. `ref` may be a build number (digits,
726
+ * resolved via filter[version] within the app) or an ASC build id.
727
+ */
728
+ async function resolveBuild(appId, ref) {
729
+ if (/^\d+$/.test(String(ref))) {
730
+ if (!appId) throw new Error("appId is required to resolve a build by its number.");
731
+ const page = await client.get(`/builds`, {
732
+ "filter[app]": appId,
733
+ "filter[version]": String(ref),
734
+ limit: 1,
735
+ });
736
+ const b = page.data?.[0];
737
+ if (!b) throw new Error(`No build numbered ${ref} found for app ${appId}.`);
738
+ return { id: b.id, attributes: b.attributes || {} };
739
+ }
740
+ const res = await client.get(`/builds/${ref}`);
741
+ return { id: res.data.id, attributes: res.data.attributes || {} };
742
+ }
743
+
744
+ /**
745
+ * Inspect a version for the usual silent submit blockers and return a list of
746
+ * issues. Covers: no build attached, build not VALID, unset export compliance,
747
+ * unset IDFA declaration, and screenshots/previews still processing.
748
+ */
749
+ async function diagnoseSubmitBlockers(appId, versionId) {
750
+ const issues = [];
751
+ let versionAttrs = {};
752
+ try {
753
+ const v = await client.get(`/appStoreVersions/${versionId}`, {
754
+ include: "build",
755
+ });
756
+ versionAttrs = v.data?.attributes || {};
757
+ const buildRel = v.data?.relationships?.build?.data;
758
+ if (!buildRel) {
759
+ issues.push({
760
+ area: "build",
761
+ issue: "No build is attached to this version.",
762
+ fix: "attach_build_to_version(versionId, buildId|buildNumber).",
763
+ });
764
+ } else {
765
+ try {
766
+ const b = await client.get(`/builds/${buildRel.id}`);
767
+ const ba = b.data?.attributes || {};
768
+ if (ba.processingState && ba.processingState !== "VALID")
769
+ issues.push({
770
+ area: "build",
771
+ issue: `Attached build is ${ba.processingState}, not VALID.`,
772
+ fix: "Wait for processing (wait_for_build_processing) before submitting.",
773
+ });
774
+ if (ba.usesNonExemptEncryption == null)
775
+ issues.push({
776
+ area: "export-compliance",
777
+ issue: "Build's usesNonExemptEncryption is unset (per-build; does not carry over).",
778
+ fix: "update_build(buildId, usesNonExemptEncryption:false|true). This is a common cause of the opaque 409.",
779
+ });
780
+ } catch {
781
+ /* build fetch best-effort */
782
+ }
783
+ }
784
+ if (versionAttrs.usesIdfa == null)
785
+ issues.push({
786
+ area: "idfa",
787
+ issue: "Version's usesIdfa is unset.",
788
+ fix: "update_app_store_version(versionId, usesIdfa:false|true).",
789
+ });
790
+ } catch (e) {
791
+ issues.push({ area: "version", issue: `Could not read version: ${e.message}` });
792
+ }
793
+ // Screenshots / previews still processing anywhere block submission.
794
+ try {
795
+ const stuck = await findIncompleteAssets(versionId);
796
+ if (stuck.screenshots.length || stuck.previews.length)
797
+ issues.push({
798
+ area: "assets",
799
+ issue: `${stuck.screenshots.length} screenshot(s) and ${stuck.previews.length} preview(s) not COMPLETE (ASC shows "uploads in progress").`,
800
+ fix: "Wait for processing; see find_incomplete_screenshots(versionId) for the exact assets.",
801
+ locales: [...new Set([...stuck.screenshots, ...stuck.previews].map((s) => s.locale))].slice(0, 10),
802
+ });
803
+ } catch {
804
+ /* best-effort */
805
+ }
806
+ return issues;
807
+ }
808
+
809
+ /** Find screenshots/previews whose assetDeliveryState.state != COMPLETE. */
810
+ async function findIncompleteAssets(versionId) {
811
+ const screenshots = [];
812
+ const previews = [];
813
+ const locs = await client.getAll(
814
+ `/appStoreVersions/${versionId}/appStoreVersionLocalizations`,
815
+ );
816
+ await mapLimit(locs, 6, async (loc) => {
817
+ const locale = loc.attributes?.locale;
818
+ const sets = await client.getAll(`/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`);
819
+ await mapLimit(sets, 4, async (set) => {
820
+ const shots = await client.getAll(`/appScreenshotSets/${set.id}/appScreenshots`);
821
+ for (const sh of shots) {
822
+ const state = sh.attributes?.assetDeliveryState?.state;
823
+ if (state && state !== "COMPLETE")
824
+ screenshots.push({
825
+ id: sh.id,
826
+ locale,
827
+ displayType: set.attributes?.screenshotDisplayType,
828
+ fileName: sh.attributes?.fileName,
829
+ state,
830
+ });
831
+ }
832
+ });
833
+ const pvSets = await client.getAll(`/appStoreVersionLocalizations/${loc.id}/appPreviewSets`);
834
+ await mapLimit(pvSets, 4, async (set) => {
835
+ const pvs = await client.getAll(`/appPreviewSets/${set.id}/appPreviews`);
836
+ for (const pv of pvs) {
837
+ const state = pv.attributes?.assetDeliveryState?.state;
838
+ if (state && state !== "COMPLETE")
839
+ previews.push({
840
+ id: pv.id,
841
+ locale,
842
+ previewType: set.attributes?.previewType,
843
+ fileName: pv.attributes?.fileName,
844
+ state,
845
+ });
846
+ }
847
+ });
848
+ });
849
+ return { screenshots, previews };
850
+ }
851
+
852
+ /** Reserve → upload → commit one screenshot file into a set. Returns a compact row. */
853
+ async function uploadScreenshotFile(setId, filePath, fileName) {
854
+ const buf = readFileSync(filePath);
855
+ const name = fileName || basename(filePath);
856
+ const reservation = await client.post(`/appScreenshots`, {
857
+ data: {
858
+ type: "appScreenshots",
859
+ attributes: { fileName: name, fileSize: buf.length },
860
+ relationships: {
861
+ appScreenshotSet: { data: { type: "appScreenshotSets", id: setId } },
862
+ },
863
+ },
864
+ });
865
+ const id = reservation.data.id;
866
+ await client.uploadAsset(reservation.data.attributes.uploadOperations, buf);
867
+ const committed = await client.patch(`/appScreenshots/${id}`, {
868
+ data: {
869
+ type: "appScreenshots",
870
+ id,
871
+ attributes: { uploaded: true, sourceFileChecksum: AppStoreConnectClient.md5(buf) },
872
+ },
873
+ });
874
+ const at = committed?.data?.attributes || {};
875
+ return { id, fileName: at.fileName || name, state: at.assetDeliveryState?.state || "UPLOAD_COMPLETE" };
876
+ }
877
+
123
878
  /** Throw a friendly install-guidance error if Xcode CLI tools aren't available. */
124
879
  async function ensureXcode() {
125
880
  if (process.platform !== "darwin")
@@ -491,15 +1246,34 @@ const tools = [
491
1246
  const attributes = { locale: a.locale };
492
1247
  for (const k of ["name", "subtitle", "privacyPolicyUrl", "privacyPolicyText"])
493
1248
  if (a[k] !== undefined) attributes[k] = a[k];
494
- return client.post(`/appInfoLocalizations`, {
495
- data: {
496
- type: "appInfoLocalizations",
497
- attributes,
498
- relationships: {
499
- appInfo: { data: { type: "appInfos", id: a.appInfoId } },
1249
+ // Upsert: Apple auto-creates the name/subtitle entry for a locale as soon
1250
+ // as that locale's version localization exists, so a plain POST can 409
1251
+ // with "already exists". On conflict, find the existing row and PATCH it.
1252
+ try {
1253
+ return await client.post(`/appInfoLocalizations`, {
1254
+ data: {
1255
+ type: "appInfoLocalizations",
1256
+ attributes,
1257
+ relationships: {
1258
+ appInfo: { data: { type: "appInfos", id: a.appInfoId } },
1259
+ },
500
1260
  },
501
- },
502
- });
1261
+ });
1262
+ } catch (e) {
1263
+ const conflict = e.status === 409 || /already exist/i.test(e.message || "");
1264
+ if (!conflict) throw e;
1265
+ const existing = await client.getAll(
1266
+ `/appInfos/${a.appInfoId}/appInfoLocalizations`,
1267
+ );
1268
+ const row = existing.find((x) => x.attributes?.locale === a.locale);
1269
+ if (!row) throw e;
1270
+ const patchAttrs = { ...attributes };
1271
+ delete patchAttrs.locale; // locale is immutable on update
1272
+ const res = await client.patch(`/appInfoLocalizations/${row.id}`, {
1273
+ data: { type: "appInfoLocalizations", id: row.id, attributes: patchAttrs },
1274
+ });
1275
+ return { ...res, _upserted: "updated existing localization for this locale" };
1276
+ }
503
1277
  },
504
1278
  },
505
1279
 
@@ -570,17 +1344,32 @@ const tools = [
570
1344
  {
571
1345
  name: "list_app_store_version_localizations",
572
1346
  description:
573
- "List the per-locale localizations of an App Store version. Each holds: description, keywords, promotionalText, whatsNew, marketingUrl, supportUrl. Use the localization id to read/update copy and to find screenshot sets.",
1347
+ "List the per-locale localizations of an App Store version. Each holds: description, keywords, promotionalText, whatsNew, marketingUrl, supportUrl. Use the localization id to read/update copy and to find screenshot sets. Pass omitLongFields:true to replace description/promotionalText/whatsNew with their character counts (descriptionLength etc.) — much lighter when scanning many locales.",
574
1348
  inputSchema: {
575
1349
  type: "object",
576
- properties: { versionId: { type: "string" } },
1350
+ properties: {
1351
+ versionId: { type: "string" },
1352
+ omitLongFields: {
1353
+ type: "boolean",
1354
+ description: "Return lengths instead of full description/promotionalText/whatsNew text",
1355
+ },
1356
+ },
577
1357
  required: ["versionId"],
578
1358
  },
579
1359
  run: async (a) => {
580
1360
  const data = await client.getAll(
581
1361
  `/appStoreVersions/${a.versionId}/appStoreVersionLocalizations`,
582
1362
  );
583
- return data.map((x) => ({ id: x.id, ...x.attributes }));
1363
+ return data.map((x) => {
1364
+ const attrs = { ...x.attributes };
1365
+ if (a.omitLongFields) {
1366
+ for (const f of ["description", "promotionalText", "whatsNew"]) {
1367
+ attrs[`${f}Length`] = (attrs[f] || "").length;
1368
+ delete attrs[f];
1369
+ }
1370
+ }
1371
+ return { id: x.id, ...attrs };
1372
+ });
584
1373
  },
585
1374
  },
586
1375
  {
@@ -716,30 +1505,31 @@ const tools = [
716
1505
  {
717
1506
  name: "create_screenshot_set",
718
1507
  description:
719
- "Create a screenshot set for a given device display type on a version localization. displayType examples: APP_IPHONE_67, APP_IPHONE_65, APP_IPHONE_61, APP_IPAD_PRO_129, APP_IPAD_PRO_3GEN_11.",
1508
+ "Create a screenshot set for a given device display type on a localization. Attach to ONE of: a normal App Store version localization (localizationId), a Custom Product Page localization (customProductPageLocalizationId), or a Product Page Optimization experiment-treatment localization (treatmentLocalizationId). displayType examples: APP_IPHONE_67, APP_IPHONE_65, APP_IPHONE_61, APP_IPAD_PRO_129, APP_IPAD_PRO_3GEN_11. Upload images into the set with upload_screenshot.",
720
1509
  inputSchema: {
721
1510
  type: "object",
722
1511
  properties: {
723
- localizationId: { type: "string" },
1512
+ localizationId: { type: "string", description: "App Store version localization id" },
1513
+ customProductPageLocalizationId: { type: "string", description: "Custom Product Page localization id" },
1514
+ treatmentLocalizationId: { type: "string", description: "PPO experiment treatment localization id" },
724
1515
  displayType: { type: "string" },
725
1516
  },
726
- required: ["localizationId", "displayType"],
1517
+ required: ["displayType"],
1518
+ },
1519
+ run: async (a) => {
1520
+ let relationships;
1521
+ if (a.localizationId)
1522
+ relationships = { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: a.localizationId } } };
1523
+ else if (a.customProductPageLocalizationId)
1524
+ relationships = { appCustomProductPageLocalization: { data: { type: "appCustomProductPageLocalizations", id: a.customProductPageLocalizationId } } };
1525
+ else if (a.treatmentLocalizationId)
1526
+ relationships = { appStoreVersionExperimentTreatmentLocalization: { data: { type: "appStoreVersionExperimentTreatmentLocalizations", id: a.treatmentLocalizationId } } };
1527
+ else
1528
+ return { error: "Provide one of localizationId, customProductPageLocalizationId, or treatmentLocalizationId." };
1529
+ return client.post(`/appScreenshotSets`, {
1530
+ data: { type: "appScreenshotSets", attributes: { screenshotDisplayType: a.displayType }, relationships },
1531
+ });
727
1532
  },
728
- run: async (a) =>
729
- client.post(`/appScreenshotSets`, {
730
- data: {
731
- type: "appScreenshotSets",
732
- attributes: { screenshotDisplayType: a.displayType },
733
- relationships: {
734
- appStoreVersionLocalization: {
735
- data: {
736
- type: "appStoreVersionLocalizations",
737
- id: a.localizationId,
738
- },
739
- },
740
- },
741
- },
742
- }),
743
1533
  },
744
1534
  {
745
1535
  name: "list_screenshots",
@@ -805,7 +1595,16 @@ const tools = [
805
1595
  },
806
1596
  },
807
1597
  });
808
- return committed;
1598
+ // Return a compact result — the raw response carries large signed upload
1599
+ // URLs that waste context and are useless after commit.
1600
+ const at = committed?.data?.attributes || {};
1601
+ return {
1602
+ id,
1603
+ fileName: at.fileName || fileName,
1604
+ fileSize: at.fileSize ?? buf.length,
1605
+ state: at.assetDeliveryState?.state || "UPLOAD_COMPLETE",
1606
+ uploaded: true,
1607
+ };
809
1608
  },
810
1609
  },
811
1610
  {
@@ -883,23 +1682,31 @@ const tools = [
883
1682
  {
884
1683
  name: "create_app_preview_set",
885
1684
  description:
886
- "Create an app preview (video) set for a device type on a version localization. previewType examples: IPHONE_67, IPHONE_61, IPAD_PRO_3GEN_129.",
1685
+ "Create an app preview (video) set for a device type on a localization. Attach to ONE of: a version localization (localizationId), a Custom Product Page localization (customProductPageLocalizationId), or a PPO treatment localization (treatmentLocalizationId). previewType examples: IPHONE_67, IPHONE_61, IPAD_PRO_3GEN_129.",
887
1686
  inputSchema: {
888
1687
  type: "object",
889
1688
  properties: {
890
1689
  localizationId: { type: "string" },
1690
+ customProductPageLocalizationId: { type: "string" },
1691
+ treatmentLocalizationId: { type: "string" },
891
1692
  previewType: { type: "string" },
892
1693
  },
893
- required: ["localizationId", "previewType"],
1694
+ required: ["previewType"],
1695
+ },
1696
+ run: async (a) => {
1697
+ let relationships;
1698
+ if (a.localizationId)
1699
+ relationships = { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: a.localizationId } } };
1700
+ else if (a.customProductPageLocalizationId)
1701
+ relationships = { appCustomProductPageLocalization: { data: { type: "appCustomProductPageLocalizations", id: a.customProductPageLocalizationId } } };
1702
+ else if (a.treatmentLocalizationId)
1703
+ relationships = { appStoreVersionExperimentTreatmentLocalization: { data: { type: "appStoreVersionExperimentTreatmentLocalizations", id: a.treatmentLocalizationId } } };
1704
+ else
1705
+ return { error: "Provide one of localizationId, customProductPageLocalizationId, or treatmentLocalizationId." };
1706
+ return client.post(`/appPreviewSets`, {
1707
+ data: { type: "appPreviewSets", attributes: { previewType: a.previewType }, relationships },
1708
+ });
894
1709
  },
895
- run: async (a) =>
896
- client.post(`/appPreviewSets`, {
897
- data: {
898
- type: "appPreviewSets",
899
- attributes: { previewType: a.previewType },
900
- relationships: { appStoreVersionLocalization: { data: { type: "appStoreVersionLocalizations", id: a.localizationId } } },
901
- },
902
- }),
903
1710
  },
904
1711
  {
905
1712
  name: "list_app_previews",
@@ -1287,23 +2094,39 @@ const tools = [
1287
2094
  {
1288
2095
  name: "list_analytics_reports",
1289
2096
  description:
1290
- "List the reports available under an analytics report request (from request_analytics_report). category filter: APP_USAGE, APP_STORE_ENGAGEMENT, COMMERCE, FRAMEWORK_USAGE, PERFORMANCE.",
2097
+ "List the reports available under an analytics report request. Pass requestId (from request_analytics_report) OR just appId — with appId, the newest existing report request for the app is used (if none exists, you're told to call request_analytics_report first). category filter: APP_USAGE, APP_STORE_ENGAGEMENT, COMMERCE, FRAMEWORK_USAGE, PERFORMANCE.",
1291
2098
  inputSchema: {
1292
2099
  type: "object",
1293
2100
  properties: {
1294
- requestId: { type: "string" },
2101
+ requestId: { type: "string", description: "An analyticsReportRequest id" },
2102
+ appId: { type: "string", description: "Alternative to requestId: use the app's newest report request" },
1295
2103
  category: { type: "string" },
1296
2104
  },
1297
- required: ["requestId"],
1298
2105
  },
1299
2106
  run: async (a) => {
2107
+ let requestId = a.requestId;
2108
+ if (!requestId) {
2109
+ if (!a.appId)
2110
+ return { error: "Pass requestId or appId." };
2111
+ const reqs = await client.getAll(`/apps/${a.appId}/analyticsReportRequests`, {
2112
+ limit: 50,
2113
+ });
2114
+ if (!reqs.length)
2115
+ return {
2116
+ error:
2117
+ "No analytics report request exists for this app yet. Call request_analytics_report first (generation is async and can take minutes–hours).",
2118
+ };
2119
+ // Prefer an ONGOING request, else the first returned.
2120
+ requestId =
2121
+ (reqs.find((r) => r.attributes?.accessType === "ONGOING") || reqs[0]).id;
2122
+ }
1300
2123
  const q = {};
1301
2124
  if (a.category) q["filter[category]"] = a.category;
1302
2125
  const data = await client.getAll(
1303
- `/analyticsReportRequests/${a.requestId}/reports`,
2126
+ `/analyticsReportRequests/${requestId}/reports`,
1304
2127
  q,
1305
2128
  );
1306
- return data.map((x) => ({ id: x.id, ...x.attributes }));
2129
+ return { requestId, reports: data.map((x) => ({ id: x.id, ...x.attributes })) };
1307
2130
  },
1308
2131
  },
1309
2132
  {
@@ -1441,23 +2264,31 @@ const tools = [
1441
2264
  {
1442
2265
  name: "list_builds",
1443
2266
  description:
1444
- "List TestFlight builds for an app (newest first): version, upload/expiration dates, processing state, min OS.",
2267
+ "List TestFlight builds for an app (newest first): version, upload/expiration dates, processing state, min OS. Optionally filter by build number (version) or processing state.",
1445
2268
  inputSchema: {
1446
2269
  type: "object",
1447
2270
  properties: {
1448
2271
  appId: { type: "string" },
1449
- limit: { type: "number", description: "Max builds (default 25)" },
2272
+ limit: { type: "number", description: "Max builds to return (default 25)" },
2273
+ version: { type: "string", description: "Filter to a specific build number, e.g. '42'" },
2274
+ processingState: { type: "string", description: "PROCESSING, VALID, INVALID, FAILED" },
1450
2275
  },
1451
2276
  required: ["appId"],
1452
2277
  },
1453
2278
  run: async (a) => {
1454
- // The /builds collection supports sort; the app relationship does not.
1455
- const data = await client.getAll(`/builds`, {
2279
+ // Use a single page sized to the requested limit — getAll() would follow
2280
+ // links.next and return far more than `limit` asks for (per-page size).
2281
+ const limit = a.limit ?? 25;
2282
+ const query = {
1456
2283
  "filter[app]": a.appId,
1457
2284
  sort: "-version",
1458
- limit: a.limit ?? 25,
1459
- });
1460
- return data.map((x) => ({ id: x.id, ...x.attributes }));
2285
+ limit: Math.min(Math.max(limit, 1), 200),
2286
+ };
2287
+ if (a.version) query["filter[version]"] = a.version;
2288
+ if (a.processingState) query["filter[processingState]"] = a.processingState;
2289
+ const page = await client.get(`/builds`, query);
2290
+ const data = Array.isArray(page.data) ? page.data : [];
2291
+ return data.slice(0, limit).map((x) => ({ id: x.id, ...x.attributes }));
1461
2292
  },
1462
2293
  },
1463
2294
  {
@@ -1962,35 +2793,63 @@ const tools = [
1962
2793
  required: ["appId", "versionId"],
1963
2794
  },
1964
2795
  run: async (a) => {
1965
- const sub = await client.post("/reviewSubmissions", {
1966
- data: {
1967
- type: "reviewSubmissions",
1968
- attributes: { platform: a.platform || "IOS" },
1969
- relationships: { app: { data: { type: "apps", id: a.appId } } },
1970
- },
1971
- });
1972
- const subId = sub.data.id;
1973
- await client.post("/reviewSubmissionItems", {
1974
- data: {
1975
- type: "reviewSubmissionItems",
1976
- relationships: {
1977
- reviewSubmission: {
1978
- data: { type: "reviewSubmissions", id: subId },
1979
- },
1980
- appStoreVersion: {
1981
- data: { type: "appStoreVersions", id: a.versionId },
2796
+ // Pre-flight: surface silent blockers before Apple's opaque 409.
2797
+ const preIssues = await diagnoseSubmitBlockers(a.appId, a.versionId).catch(() => []);
2798
+ if (preIssues.length)
2799
+ return {
2800
+ submitted: false,
2801
+ blocked: true,
2802
+ reason:
2803
+ "Not submitting — the version has blockers Apple would reject with an opaque 409. Fix these and retry:",
2804
+ issues: preIssues,
2805
+ };
2806
+ let subId;
2807
+ try {
2808
+ const sub = await client.post("/reviewSubmissions", {
2809
+ data: {
2810
+ type: "reviewSubmissions",
2811
+ attributes: { platform: a.platform || "IOS" },
2812
+ relationships: { app: { data: { type: "apps", id: a.appId } } },
2813
+ },
2814
+ });
2815
+ subId = sub.data.id;
2816
+ await client.post("/reviewSubmissionItems", {
2817
+ data: {
2818
+ type: "reviewSubmissionItems",
2819
+ relationships: {
2820
+ reviewSubmission: { data: { type: "reviewSubmissions", id: subId } },
2821
+ appStoreVersion: { data: { type: "appStoreVersions", id: a.versionId } },
1982
2822
  },
1983
2823
  },
1984
- },
1985
- });
1986
- const submitted = await client.patch(`/reviewSubmissions/${subId}`, {
1987
- data: {
1988
- type: "reviewSubmissions",
1989
- id: subId,
1990
- attributes: { submitted: true },
1991
- },
1992
- });
1993
- return { reviewSubmissionId: subId, result: submitted };
2824
+ });
2825
+ const submitted = await client.patch(`/reviewSubmissions/${subId}`, {
2826
+ data: {
2827
+ type: "reviewSubmissions",
2828
+ id: subId,
2829
+ attributes: { submitted: true },
2830
+ },
2831
+ });
2832
+ return { submitted: true, reviewSubmissionId: subId, result: submitted };
2833
+ } catch (e) {
2834
+ // On failure, diagnose and surface the likely real cause; clean up the
2835
+ // half-created (empty) submission so it doesn't orphan.
2836
+ const issues = await diagnoseSubmitBlockers(a.appId, a.versionId).catch(() => []);
2837
+ if (subId) {
2838
+ try {
2839
+ await client.patch(`/reviewSubmissions/${subId}`, {
2840
+ data: { type: "reviewSubmissions", id: subId, attributes: { canceled: true } },
2841
+ });
2842
+ } catch {
2843
+ /* best-effort cleanup */
2844
+ }
2845
+ }
2846
+ return {
2847
+ submitted: false,
2848
+ error: e.message,
2849
+ likelyCauses: issues.length ? issues : "No specific blocker detected — see the raw error above.",
2850
+ reviewSubmissionId: subId,
2851
+ };
2852
+ }
1994
2853
  },
1995
2854
  },
1996
2855
  {
@@ -2628,20 +3487,52 @@ const tools = [
2628
3487
  const ed = versions.find((v) => EDITABLE_VERSION_STATES.has(v.attributes.appStoreState)) || versions[0];
2629
3488
  add("Version", ed ? "info" : "warn", ed ? `v${ed.attributes.versionString} — ${ed.attributes.appStoreState}` : "no version");
2630
3489
  if (ed) {
3490
+ // Is a build attached? (common silent submit blocker)
3491
+ try {
3492
+ const vFull = await client.get(`/appStoreVersions/${ed.id}`, { include: "build" });
3493
+ const buildRel = vFull.data?.relationships?.build?.data;
3494
+ if (!buildRel) add("Attached build", "fail", "no build attached to this version");
3495
+ else {
3496
+ const b = await client.get(`/builds/${buildRel.id}`);
3497
+ const ba = b.data?.attributes || {};
3498
+ add("Attached build", ba.processingState === "VALID" ? "pass" : "warn",
3499
+ `v${ba.version} — ${ba.processingState}` +
3500
+ (ba.usesNonExemptEncryption == null ? " · export compliance UNSET" : ""));
3501
+ }
3502
+ } catch (e) { add("Attached build", "warn", e.message.slice(0, 60)); }
3503
+
2631
3504
  const locs = await client.getAll(`/appStoreVersions/${ed.id}/appStoreVersionLocalizations`);
2632
3505
  const loc = locs.find((l) => l.attributes.locale === primaryLocale) || locs[0];
2633
3506
  const at = (loc && loc.attributes) || {};
2634
- add("Description", at.description ? "pass" : "fail", at.description ? "present" : "missing");
3507
+ add("Description", at.description ? "pass" : "fail", at.description ? `present (${primaryLocale})` : "missing");
2635
3508
  const kw = (at.keywords || "").trim();
2636
3509
  add("Keywords", kw ? (kw.length >= 70 ? "pass" : "warn") : "fail", kw ? `${kw.length}/100 chars` : "empty");
2637
3510
  add("What's New", at.whatsNew ? "pass" : "warn", at.whatsNew ? "present" : "missing");
2638
3511
  add("Support URL", at.supportUrl ? "pass" : "warn", at.supportUrl ? "set" : "missing");
2639
- if (loc) {
3512
+
3513
+ // Every locale: description + at least one screenshot.
3514
+ const noDesc = [];
3515
+ const noShots = [];
3516
+ await mapLimit(locs, 6, async (l) => {
3517
+ if (!(l.attributes?.description || "").trim()) noDesc.push(l.attributes?.locale);
2640
3518
  let shots = 0;
2641
- const sets = await client.getAll(`/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`);
2642
- for (const s of sets) shots += (await client.getAll(`/appScreenshotSets/${s.id}/appScreenshots`)).length;
2643
- add("Screenshots", shots > 0 ? "pass" : "fail", `${shots} on ${loc.attributes.locale}`);
2644
- }
3519
+ const sets = await client.getAll(`/appStoreVersionLocalizations/${l.id}/appScreenshotSets`);
3520
+ for (const s of sets)
3521
+ shots += (await client.getAll(`/appScreenshotSets/${s.id}/appScreenshots`)).length;
3522
+ if (shots === 0) noShots.push(l.attributes?.locale);
3523
+ });
3524
+ add("Description (all locales)", noDesc.length ? "fail" : "pass",
3525
+ noDesc.length ? `missing in: ${noDesc.join(", ")}` : `${locs.length} locale(s) OK`);
3526
+ add("Screenshots (all locales)", noShots.length ? "fail" : "pass",
3527
+ noShots.length ? `none in: ${noShots.join(", ")}` : `${locs.length} locale(s) have screenshots`);
3528
+
3529
+ // Any asset still processing blocks submission.
3530
+ try {
3531
+ const stuck = await findIncompleteAssets(ed.id);
3532
+ const n = stuck.screenshots.length + stuck.previews.length;
3533
+ add("Asset processing", n ? "fail" : "pass",
3534
+ n ? `${n} asset(s) not COMPLETE (e.g. ${stuck.screenshots.concat(stuck.previews).slice(0, 3).map((x) => `${x.locale} ${x.fileName || ""}`).join("; ")})` : "all COMPLETE");
3535
+ } catch { /* best-effort */ }
2645
3536
  }
2646
3537
  try {
2647
3538
  const infos = await client.getAll(`/apps/${a.appId}/appInfos`);
@@ -2940,6 +3831,1852 @@ ${a.teamId ? `<key>teamID</key><string>${a.teamId}</string>\n` : ""}<key>uploadS
2940
3831
  },
2941
3832
  },
2942
3833
 
3834
+ // ---- PPP (purchasing-power-parity) regional pricing ----
3835
+ {
3836
+ name: "list_purchasable_products",
3837
+ description:
3838
+ "List every purchasable product for an app — non-subscription in-app purchases (consumables/non-consumables) AND auto-renewable subscriptions — each with its current US price. Use this to find the productId to feed preview_ppp_prices / apply_ppp_prices.",
3839
+ inputSchema: {
3840
+ type: "object",
3841
+ properties: {
3842
+ appId: { type: "string", description: "App Store app id (9-digit)" },
3843
+ },
3844
+ required: ["appId"],
3845
+ },
3846
+ run: async (a) => {
3847
+ const products = await pppFetchProducts(a.appId);
3848
+ const withPrices = await mapLimit(products, 10, async (p) => ({
3849
+ productId: p.productId,
3850
+ name: p.name,
3851
+ type: p.isSubscription ? "SUB" : "IAP",
3852
+ usPrice: await pppFetchUsPrice(p).catch(() => null),
3853
+ }));
3854
+ return { appId: a.appId, count: withPrices.length, products: withPrices };
3855
+ },
3856
+ },
3857
+ {
3858
+ name: "preview_ppp_prices",
3859
+ description:
3860
+ "Dry run (READ-ONLY) for purchasing-power-parity regional pricing of ONE in-app purchase or subscription. Computes a per-territory price table from the US base price scaled by each country's PPP coefficient, snapped to Apple's real local price grid (scaling happens in local currency via equalizations, not dollars). ALWAYS run this and show the user the table before apply_ppp_prices — price changes affect real customers in ~174 territories and cannot be undone. " +
3861
+ "Coefficients: the embedded per-country defaults are used unless you pass `coefficients` to override a tier. Tiers: premium, high_income, upper_middle, lower_middle, emerging (USA is always the 1.00 base). Reason about the app's type/elasticity (games = high elasticity → discount more in poorer markets; AI/productivity = low elasticity → discount less) and pass overrides like {\"emerging\":0.55,\"lower_middle\":0.70}.",
3862
+ inputSchema: {
3863
+ type: "object",
3864
+ properties: {
3865
+ appId: { type: "string", description: "App Store app id" },
3866
+ productId: { type: "string", description: "The product's productId (e.g. com.app.weekly) or its ASC id" },
3867
+ usPrice: { type: "number", description: "Override the US base price (also becomes the new USA price on apply)" },
3868
+ coefficients: {
3869
+ type: "object",
3870
+ description: "Per-tier multiplier overrides, e.g. {\"emerging\":0.55}. Each 0.1–2.0. Categories: premium, high_income, upper_middle, lower_middle, emerging.",
3871
+ additionalProperties: { type: "number" },
3872
+ },
3873
+ exclude: {
3874
+ type: "array",
3875
+ description: "3-letter territory codes to exclude (e.g. [\"RUS\",\"BLR\"])",
3876
+ items: { type: "string" },
3877
+ },
3878
+ },
3879
+ required: ["appId", "productId"],
3880
+ },
3881
+ run: async (a) => {
3882
+ const r = await pppResolve(a);
3883
+ if (r.error) return r;
3884
+ const currencies = await pppFetchCurrencies();
3885
+ return {
3886
+ dryRun: true,
3887
+ product: { productId: r.product.productId, name: r.product.name, type: r.product.isSubscription ? "SUB" : "IAP" },
3888
+ usPrice: r.usPrice,
3889
+ usaTierPrice: r.usTier.customerPrice,
3890
+ coefficientOverrides: r.overrides,
3891
+ territoriesResolved: Object.keys(r.resolved).length,
3892
+ tiers: tierSummary(),
3893
+ prices: r.rows.map((row) => ({ ...row, currency: currencies[row.territory] })),
3894
+ note: "No changes were written. Review this table with the user, then call apply_ppp_prices with confirm:true to apply.",
3895
+ };
3896
+ },
3897
+ },
3898
+ {
3899
+ name: "apply_ppp_prices",
3900
+ description:
3901
+ "APPLY purchasing-power-parity regional prices for ONE in-app purchase or subscription across ~174 territories. IRREVERSIBLE and customer-facing. Requires confirm:true, and is blocked in read-only mode or when APPSTORE_MCP_ALLOW_PRICE_CHANGES=false. ALWAYS run preview_ppp_prices first and get the user's explicit go-ahead. " +
3902
+ "IAPs are set in a single atomic price-schedule request. Subscriptions are set per-territory; by default preserveCurrentPrice keeps existing subscribers on their current price and the change starts in 2 days (override with preserveCurrentPrice/startDate). Pending (future-dated) subscription prices are cleared first to avoid conflicts. Takes the same pricing inputs as preview_ppp_prices.",
3903
+ inputSchema: {
3904
+ type: "object",
3905
+ properties: {
3906
+ appId: { type: "string" },
3907
+ productId: { type: "string" },
3908
+ confirm: { type: "boolean", description: "Must be true. Safety gate acknowledging this changes real customer prices." },
3909
+ usPrice: { type: "number", description: "Override the US base price (also sets the new USA price)" },
3910
+ coefficients: {
3911
+ type: "object",
3912
+ description: "Per-tier multiplier overrides (same as preview_ppp_prices).",
3913
+ additionalProperties: { type: "number" },
3914
+ },
3915
+ exclude: { type: "array", items: { type: "string" } },
3916
+ preserveCurrentPrice: {
3917
+ type: "boolean",
3918
+ description: "Subscriptions only: keep existing subscribers on their current price (default true).",
3919
+ },
3920
+ startDate: {
3921
+ type: "string",
3922
+ description: "Subscriptions only: YYYY-MM-DD the new prices take effect (default: 2 days from now).",
3923
+ },
3924
+ },
3925
+ required: ["appId", "productId", "confirm"],
3926
+ },
3927
+ run: async (a) => {
3928
+ if (a.confirm !== true)
3929
+ return {
3930
+ error:
3931
+ "Refused: apply_ppp_prices needs confirm:true. Run preview_ppp_prices, show the user the table, get explicit approval, then re-call with confirm:true.",
3932
+ };
3933
+ // Default preserveCurrentPrice to true for subscriptions (safer for existing subscribers).
3934
+ const preserved = a.preserveCurrentPrice === undefined ? true : a.preserveCurrentPrice;
3935
+ const r = await pppResolve({ ...a, preserveCurrentPrice: preserved });
3936
+ if (r.error) return r;
3937
+ const result = await pppApply(r.product, r.resolved, {
3938
+ preserved,
3939
+ startDate: a.startDate,
3940
+ });
3941
+ return {
3942
+ applied: true,
3943
+ product: { productId: r.product.productId, name: r.product.name, type: r.product.isSubscription ? "SUB" : "IAP" },
3944
+ usPrice: r.usPrice,
3945
+ ...result,
3946
+ };
3947
+ },
3948
+ },
3949
+
3950
+ // ---- iOS CI/CD → TestFlight bootstrap ----
3951
+ {
3952
+ name: "ensure_asc_app",
3953
+ description:
3954
+ "Find the App Store Connect app record for a bundle id. Returns {app_id, created, bundleId, name}. NOTE: the public App Store Connect API cannot CREATE an app record (there is no POST /apps) — so this is find-only and always returns created:false. If the app doesn't exist yet, it returns found:false plus guidance: register the bundle id (register_bundle_id) and create the record once in the App Store Connect web UI (the first TestFlight upload also can't create it).",
3955
+ inputSchema: {
3956
+ type: "object",
3957
+ properties: {
3958
+ bundleId: { type: "string", description: "e.g. com.example.app" },
3959
+ name: { type: "string", description: "Unused for lookup; echoed back for convenience." },
3960
+ sku: { type: "string" },
3961
+ platform: { type: "string", description: "IOS (default)" },
3962
+ primaryLocale: { type: "string" },
3963
+ },
3964
+ required: ["bundleId"],
3965
+ },
3966
+ run: async (a) => {
3967
+ const app = await findAppByBundleId(a.bundleId);
3968
+ if (app)
3969
+ return {
3970
+ app_id: app.id,
3971
+ created: false,
3972
+ found: true,
3973
+ bundleId: app.attributes?.bundleId,
3974
+ name: app.attributes?.name,
3975
+ };
3976
+ return {
3977
+ app_id: null,
3978
+ created: false,
3979
+ found: false,
3980
+ bundleId: a.bundleId,
3981
+ action_needed:
3982
+ "App record not found. The App Store Connect API cannot create it. Steps: 1) register the bundle id with register_bundle_id (if not registered), 2) create the app record in App Store Connect → My Apps → + → New App. Then re-run.",
3983
+ };
3984
+ },
3985
+ },
3986
+ {
3987
+ name: "bootstrap_ios_cicd",
3988
+ description:
3989
+ "Scaffold a fastlane + GitHub Actions → TestFlight pipeline into an iOS app's repo. Renders 7 files (Gemfile, fastlane/{Appfile,Fastfile,.gitignore,SETUP.md}, .github/workflows/{ios-ci.yml,ios-testflight.yml}) using Xcode automatic/cloud signing (-allowProvisioningUpdates, no match repo). Auto-detects appDir/bundleId/teamId/scheme/target from the repo's .xcodeproj when not given. mode: 'pr' (default; branch+push+open PR via gh), 'branch' (push only), 'commit' (commit locally), 'files' (write only). Use dryRun:true to preview detected values and rendered files without writing.",
3990
+ inputSchema: {
3991
+ type: "object",
3992
+ properties: {
3993
+ repoDir: { type: "string", description: "Local path to the repo working copy (default '.')" },
3994
+ repo: { type: "string", description: "GitHub 'owner/name' for the PR (default: derived from origin)" },
3995
+ owner: { type: "string", description: "GitHub owner, if repo is just a name" },
3996
+ appDir: { type: "string", description: "Dir containing the .xcodeproj, relative to repo root (auto-detected)" },
3997
+ bundleId: { type: "string", description: "Override auto-detected bundle id" },
3998
+ teamId: { type: "string", description: "Override auto-detected Apple Developer team id" },
3999
+ scheme: { type: "string", description: "Xcode scheme (default: project name)" },
4000
+ target: { type: "string", description: "Xcode target (default: scheme)" },
4001
+ mode: { type: "string", description: "pr (default), branch, commit, or files" },
4002
+ branch: { type: "string", description: "Branch name (default ci/ios-testflight-bootstrap)" },
4003
+ baseBranch: { type: "string", description: "PR base branch (default: current branch)" },
4004
+ dryRun: { type: "boolean", description: "Preview without writing (default false)" },
4005
+ },
4006
+ required: [],
4007
+ },
4008
+ run: async (a) => doBootstrap(a),
4009
+ },
4010
+ {
4011
+ name: "set_repo_ci_secrets",
4012
+ description:
4013
+ "Push the three CI secrets (ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8) to a GitHub repo's Actions secrets via `gh secret set`. The .p8 is base64-encoded. CRITICAL: the API key is read from THIS server's own configured environment — it is never accepted as an argument and never returned in output. gh handles the libsodium sealed-box encryption. Requires `gh` installed + authenticated with repo admin. Create the App Store Connect API key once at the TEAM level so the same secret values work for every repo.",
4014
+ inputSchema: {
4015
+ type: "object",
4016
+ properties: {
4017
+ repo: { type: "string", description: "GitHub 'owner/name' (or just the name with owner/repoDir)" },
4018
+ owner: { type: "string", description: "GitHub owner, if repo is just a name" },
4019
+ repoDir: { type: "string", description: "Local clone to derive owner/name from origin (default '.')" },
4020
+ },
4021
+ required: [],
4022
+ },
4023
+ run: async (a) => doSetSecrets(a),
4024
+ },
4025
+ {
4026
+ name: "bootstrap_testflight",
4027
+ description:
4028
+ "One-call orchestrator: turn a new iOS app into a TestFlight pipeline. Runs ensure_asc_app (find the app record) → bootstrap_ios_cicd (scaffold fastlane + Actions, open a PR) → set_repo_ci_secrets (push ASC_* secrets from this server's config). Auto-detects project settings. If the app record doesn't exist yet, it still scaffolds + sets secrets and tells you to create the record in the web UI. Pass the same options as bootstrap_ios_cicd; use dryRun:true to preview.",
4029
+ inputSchema: {
4030
+ type: "object",
4031
+ properties: {
4032
+ repoDir: { type: "string", description: "Local path to the repo (default '.')" },
4033
+ repo: { type: "string", description: "GitHub 'owner/name' (default: derived from origin)" },
4034
+ owner: { type: "string" },
4035
+ appDir: { type: "string" },
4036
+ bundleId: { type: "string", description: "Override auto-detected bundle id" },
4037
+ name: { type: "string", description: "App name (for the ensure_asc_app lookup echo)" },
4038
+ teamId: { type: "string" },
4039
+ scheme: { type: "string" },
4040
+ target: { type: "string" },
4041
+ mode: { type: "string", description: "pr (default), branch, commit, files" },
4042
+ dryRun: { type: "boolean", description: "Preview without writing or pushing (default false)" },
4043
+ },
4044
+ required: [],
4045
+ },
4046
+ run: async (a) => {
4047
+ const steps = {};
4048
+
4049
+ // 1. Scaffold (also resolves auto-detected bundleId we can reuse for the app lookup).
4050
+ steps.bootstrap = await doBootstrap(a);
4051
+ if (steps.bootstrap.error)
4052
+ return { ok: false, failedStep: "bootstrap_ios_cicd", ...steps };
4053
+ const bundleId = a.bundleId || steps.bootstrap.detected?.bundleId;
4054
+
4055
+ // 2. Find the ASC app record (find-only; never blocks the pipeline).
4056
+ try {
4057
+ const app = bundleId ? await findAppByBundleId(bundleId) : null;
4058
+ steps.app = app
4059
+ ? { app_id: app.id, created: false, found: true, bundleId, name: app.attributes?.name }
4060
+ : {
4061
+ app_id: null,
4062
+ created: false,
4063
+ found: false,
4064
+ bundleId,
4065
+ action_needed:
4066
+ "App record not found — create it in App Store Connect → My Apps → New App (the API cannot create it). CI secrets and the pipeline are still set up; TestFlight uploads will work once the record exists.",
4067
+ };
4068
+ } catch (e) {
4069
+ steps.app = { error: e.message };
4070
+ }
4071
+
4072
+ if (a.dryRun) {
4073
+ steps.secrets = { skipped: "dryRun — secrets not pushed" };
4074
+ return { ok: true, dryRun: true, ...steps };
4075
+ }
4076
+
4077
+ // 3. Push the CI secrets.
4078
+ steps.secrets = await doSetSecrets(a);
4079
+ const ok = !steps.secrets.error;
4080
+ return { ok, ...steps };
4081
+ },
4082
+ },
4083
+
4084
+ // ---- Submission flow: build ↔ version ↔ review ----
4085
+ {
4086
+ name: "attach_build_to_version",
4087
+ description:
4088
+ "Attach an already-processed build to an App Store version — the mandatory step before submitting that has no other tool. Pass the build by its ASC id (buildId) or by its number (buildNumber, resolved within the app). Uses PATCH /appStoreVersions/{id}/relationships/build.",
4089
+ inputSchema: {
4090
+ type: "object",
4091
+ properties: {
4092
+ versionId: { type: "string" },
4093
+ buildId: { type: "string", description: "ASC build id" },
4094
+ buildNumber: { type: "string", description: "Build number (needs appId to resolve)" },
4095
+ appId: { type: "string", description: "Required when using buildNumber" },
4096
+ },
4097
+ required: ["versionId"],
4098
+ },
4099
+ run: async (a) => {
4100
+ if (!a.buildId && !a.buildNumber) return { error: "Pass buildId or buildNumber." };
4101
+ const build = a.buildId
4102
+ ? await resolveBuild(a.appId, a.buildId)
4103
+ : await resolveBuild(a.appId, a.buildNumber);
4104
+ await client.patch(`/appStoreVersions/${a.versionId}/relationships/build`, {
4105
+ data: { type: "builds", id: build.id },
4106
+ });
4107
+ return {
4108
+ attached: true,
4109
+ versionId: a.versionId,
4110
+ buildId: build.id,
4111
+ buildNumber: build.attributes?.version,
4112
+ processingState: build.attributes?.processingState,
4113
+ };
4114
+ },
4115
+ },
4116
+ {
4117
+ name: "get_app_store_version",
4118
+ description:
4119
+ "Get one App Store version with its state and the build attached to it (GET /appStoreVersions/{id}?include=build). Returns appStoreState, releaseType, versionString, usesIdfa, and the attached build's number/processing state (or null if none).",
4120
+ inputSchema: {
4121
+ type: "object",
4122
+ properties: { versionId: { type: "string" } },
4123
+ required: ["versionId"],
4124
+ },
4125
+ run: async (a) => {
4126
+ const v = await client.get(`/appStoreVersions/${a.versionId}`, { include: "build" });
4127
+ const attrs = v.data?.attributes || {};
4128
+ const buildRel = v.data?.relationships?.build?.data;
4129
+ let build = null;
4130
+ if (buildRel) {
4131
+ const inc = (v.included || []).find((i) => i.type === "builds" && i.id === buildRel.id);
4132
+ build = inc
4133
+ ? { id: inc.id, version: inc.attributes?.version, processingState: inc.attributes?.processingState }
4134
+ : { id: buildRel.id };
4135
+ }
4136
+ return { id: a.versionId, ...attrs, build };
4137
+ },
4138
+ },
4139
+ {
4140
+ name: "update_app_store_version",
4141
+ description:
4142
+ "Update App Store version attributes: usesIdfa (unset → opaque 409 at submit), releaseType (MANUAL, AFTER_APPROVAL, SCHEDULED), earliestReleaseDate (ISO 8601), versionString, downloadable. Only pass fields you want to change.",
4143
+ inputSchema: {
4144
+ type: "object",
4145
+ properties: {
4146
+ versionId: { type: "string" },
4147
+ usesIdfa: { type: "boolean" },
4148
+ releaseType: { type: "string", description: "MANUAL, AFTER_APPROVAL, SCHEDULED" },
4149
+ earliestReleaseDate: { type: "string", description: "ISO 8601 (for SCHEDULED)" },
4150
+ versionString: { type: "string" },
4151
+ downloadable: { type: "boolean" },
4152
+ },
4153
+ required: ["versionId"],
4154
+ },
4155
+ run: async (a) => {
4156
+ const attributes = {};
4157
+ for (const k of ["usesIdfa", "releaseType", "earliestReleaseDate", "versionString", "downloadable"])
4158
+ if (a[k] !== undefined) attributes[k] = a[k];
4159
+ if (!Object.keys(attributes).length) return { error: "No attributes to update." };
4160
+ return client.patch(`/appStoreVersions/${a.versionId}`, {
4161
+ data: { type: "appStoreVersions", id: a.versionId, attributes },
4162
+ });
4163
+ },
4164
+ },
4165
+ {
4166
+ name: "update_build",
4167
+ description:
4168
+ "Update a build's attributes. usesNonExemptEncryption sets export compliance — this is PER-BUILD and does NOT carry over from a previous build; when unset, submit_for_review fails with an opaque 409. expired:true expires a TestFlight build. Pass buildId or buildNumber(+appId).",
4169
+ inputSchema: {
4170
+ type: "object",
4171
+ properties: {
4172
+ buildId: { type: "string" },
4173
+ buildNumber: { type: "string" },
4174
+ appId: { type: "string" },
4175
+ usesNonExemptEncryption: { type: "boolean" },
4176
+ expired: { type: "boolean" },
4177
+ },
4178
+ },
4179
+ run: async (a) => {
4180
+ const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
4181
+ const attributes = {};
4182
+ if (a.usesNonExemptEncryption !== undefined) attributes.usesNonExemptEncryption = a.usesNonExemptEncryption;
4183
+ if (a.expired !== undefined) attributes.expired = a.expired;
4184
+ if (!Object.keys(attributes).length) return { error: "Pass usesNonExemptEncryption and/or expired." };
4185
+ const res = await client.patch(`/builds/${build.id}`, {
4186
+ data: { type: "builds", id: build.id, attributes },
4187
+ });
4188
+ return { buildId: build.id, updated: attributes, result: res.data?.attributes };
4189
+ },
4190
+ },
4191
+ {
4192
+ name: "expire_build",
4193
+ description: "Expire a TestFlight build (PATCH /builds/{id} {expired:true}). Pass buildId or buildNumber(+appId).",
4194
+ inputSchema: {
4195
+ type: "object",
4196
+ properties: {
4197
+ buildId: { type: "string" },
4198
+ buildNumber: { type: "string" },
4199
+ appId: { type: "string" },
4200
+ },
4201
+ },
4202
+ run: async (a) => {
4203
+ const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
4204
+ await client.patch(`/builds/${build.id}`, {
4205
+ data: { type: "builds", id: build.id, attributes: { expired: true } },
4206
+ });
4207
+ return { buildId: build.id, buildNumber: build.attributes?.version, expired: true };
4208
+ },
4209
+ },
4210
+ {
4211
+ name: "get_build",
4212
+ description:
4213
+ "Get one build by its number (buildNumber + appId) or ASC id (buildId). Returns version, processingState, uploaded/expiration dates, min OS, export compliance.",
4214
+ inputSchema: {
4215
+ type: "object",
4216
+ properties: {
4217
+ appId: { type: "string" },
4218
+ buildNumber: { type: "string" },
4219
+ buildId: { type: "string" },
4220
+ },
4221
+ },
4222
+ run: async (a) => {
4223
+ const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
4224
+ return { id: build.id, ...build.attributes };
4225
+ },
4226
+ },
4227
+ {
4228
+ name: "wait_for_build_processing",
4229
+ description:
4230
+ "Poll until a build finishes processing (VALID) or fails (INVALID/FAILED), or the timeout elapses. Use right after upload_build to confirm a build is ready before attaching it. Returns the final state and build id.",
4231
+ inputSchema: {
4232
+ type: "object",
4233
+ properties: {
4234
+ appId: { type: "string" },
4235
+ buildNumber: { type: "string" },
4236
+ timeoutSeconds: { type: "number", description: "Max wait (default 600)" },
4237
+ pollSeconds: { type: "number", description: "Poll interval (default 15)" },
4238
+ },
4239
+ required: ["appId", "buildNumber"],
4240
+ },
4241
+ run: async (a) => {
4242
+ const timeout = (a.timeoutSeconds ?? 600) * 1000;
4243
+ const poll = Math.max((a.pollSeconds ?? 15) * 1000, 5000);
4244
+ const deadline = Date.now() + timeout;
4245
+ const terminal = new Set(["VALID", "INVALID", "FAILED"]);
4246
+ let last = null;
4247
+ while (Date.now() < deadline) {
4248
+ const page = await client.get(`/builds`, {
4249
+ "filter[app]": a.appId,
4250
+ "filter[version]": String(a.buildNumber),
4251
+ limit: 1,
4252
+ });
4253
+ const b = page.data?.[0];
4254
+ if (b) {
4255
+ last = b;
4256
+ const state = b.attributes?.processingState;
4257
+ if (terminal.has(state))
4258
+ return { buildId: b.id, buildNumber: b.attributes?.version, processingState: state, done: true };
4259
+ }
4260
+ await new Promise((r) => setTimeout(r, poll));
4261
+ }
4262
+ return {
4263
+ done: false,
4264
+ timedOut: true,
4265
+ buildId: last?.id || null,
4266
+ processingState: last?.attributes?.processingState || "UNKNOWN",
4267
+ note: "Build did not reach a terminal state before the timeout. Call again or increase timeoutSeconds.",
4268
+ };
4269
+ },
4270
+ },
4271
+ {
4272
+ name: "next_build_number",
4273
+ description:
4274
+ "Return the highest existing build number for an app and the suggested next one (+1), so you don't have to list every build before archiving.",
4275
+ inputSchema: {
4276
+ type: "object",
4277
+ properties: { appId: { type: "string" } },
4278
+ required: ["appId"],
4279
+ },
4280
+ run: async (a) => {
4281
+ const page = await client.get(`/builds`, {
4282
+ "filter[app]": a.appId,
4283
+ sort: "-version",
4284
+ limit: 20,
4285
+ });
4286
+ const nums = (page.data || [])
4287
+ .map((b) => parseInt(b.attributes?.version, 10))
4288
+ .filter((n) => Number.isFinite(n));
4289
+ const latest = nums.length ? Math.max(...nums) : 0;
4290
+ return { latestBuildNumber: latest || null, nextBuildNumber: latest + 1 };
4291
+ },
4292
+ },
4293
+ {
4294
+ name: "list_review_submissions",
4295
+ description:
4296
+ "List App Store review submissions for an app (with their items), newest-relevant first. Use to find an in-flight submission to cancel when swapping a build. Filter by state (e.g. READY_FOR_REVIEW, WAITING_FOR_REVIEW, IN_REVIEW).",
4297
+ inputSchema: {
4298
+ type: "object",
4299
+ properties: {
4300
+ appId: { type: "string" },
4301
+ state: { type: "string", description: "Optional reviewSubmissionState filter" },
4302
+ },
4303
+ required: ["appId"],
4304
+ },
4305
+ run: async (a) => {
4306
+ const q = { "filter[app]": a.appId, include: "items" };
4307
+ if (a.state) q["filter[state]"] = a.state;
4308
+ const { data } = await client.getAllPages(`/reviewSubmissions`, q);
4309
+ return data.map((x) => ({
4310
+ id: x.id,
4311
+ state: x.attributes?.state,
4312
+ platform: x.attributes?.platform,
4313
+ submitted: x.attributes?.submittedDate,
4314
+ itemCount: x.relationships?.items?.data?.length ?? null,
4315
+ canceled: x.attributes?.canceled,
4316
+ }));
4317
+ },
4318
+ },
4319
+ {
4320
+ name: "get_review_submission",
4321
+ description: "Get one review submission by id, including its app, the version under review, and its items.",
4322
+ inputSchema: {
4323
+ type: "object",
4324
+ properties: { id: { type: "string" } },
4325
+ required: ["id"],
4326
+ },
4327
+ run: async (a) =>
4328
+ client.get(`/reviewSubmissions/${a.id}`, {
4329
+ include: "app,appStoreVersionForReview,items",
4330
+ }),
4331
+ },
4332
+ {
4333
+ name: "add_review_submission_item",
4334
+ description:
4335
+ "Add an App Store version to an existing (open) review submission. POST /reviewSubmissionItems.",
4336
+ inputSchema: {
4337
+ type: "object",
4338
+ properties: {
4339
+ reviewSubmissionId: { type: "string" },
4340
+ versionId: { type: "string" },
4341
+ },
4342
+ required: ["reviewSubmissionId", "versionId"],
4343
+ },
4344
+ run: async (a) =>
4345
+ client.post(`/reviewSubmissionItems`, {
4346
+ data: {
4347
+ type: "reviewSubmissionItems",
4348
+ relationships: {
4349
+ reviewSubmission: { data: { type: "reviewSubmissions", id: a.reviewSubmissionId } },
4350
+ appStoreVersion: { data: { type: "appStoreVersions", id: a.versionId } },
4351
+ },
4352
+ },
4353
+ }),
4354
+ },
4355
+ {
4356
+ name: "cancel_review_submission",
4357
+ description:
4358
+ "Cancel (pull back) a review submission that's in review — required before you can attach a different build. PATCH /reviewSubmissions/{id} {canceled:true}. Apple refuses cancel on an empty (0-item) submission and on submissions not in a cancellable state; this surfaces that cleanly. Pass submissionId, or appId to cancel the app's current in-flight submission. Optionally waitSeconds to poll until it reaches a terminal state.",
4359
+ inputSchema: {
4360
+ type: "object",
4361
+ properties: {
4362
+ submissionId: { type: "string" },
4363
+ appId: { type: "string", description: "Cancel the app's current in-flight submission (if submissionId omitted)" },
4364
+ waitSeconds: { type: "number", description: "Poll until canceled/complete, up to this long" },
4365
+ },
4366
+ },
4367
+ run: async (a) => {
4368
+ let id = a.submissionId;
4369
+ if (!id) {
4370
+ if (!a.appId) return { error: "Pass submissionId or appId." };
4371
+ const { data } = await client.getAllPages(`/reviewSubmissions`, {
4372
+ "filter[app]": a.appId,
4373
+ include: "items",
4374
+ });
4375
+ const active = data.find((s) =>
4376
+ ["READY_FOR_REVIEW", "WAITING_FOR_REVIEW", "IN_REVIEW", "UNRESOLVED_ISSUES"].includes(
4377
+ s.attributes?.state,
4378
+ ),
4379
+ );
4380
+ if (!active)
4381
+ return { error: "No in-flight review submission found for this app." };
4382
+ id = active.id;
4383
+ }
4384
+ try {
4385
+ const res = await client.patch(`/reviewSubmissions/${id}`, {
4386
+ data: { type: "reviewSubmissions", id, attributes: { canceled: true } },
4387
+ });
4388
+ let state = res.data?.attributes?.state;
4389
+ if (a.waitSeconds) {
4390
+ const deadline = Date.now() + a.waitSeconds * 1000;
4391
+ while (Date.now() < deadline && state !== "COMPLETE" && state !== "CANCELING") {
4392
+ await new Promise((r) => setTimeout(r, 10000));
4393
+ const cur = await client.get(`/reviewSubmissions/${id}`);
4394
+ state = cur.data?.attributes?.state;
4395
+ if (state === "COMPLETE") break;
4396
+ }
4397
+ }
4398
+ return { canceled: true, submissionId: id, state };
4399
+ } catch (e) {
4400
+ return {
4401
+ canceled: false,
4402
+ submissionId: id,
4403
+ error: e.message,
4404
+ hint: "Apple refuses cancel on an empty (0-item) or non-cancellable submission. Check its state with get_review_submission.",
4405
+ };
4406
+ }
4407
+ },
4408
+ },
4409
+ {
4410
+ name: "get_app_store_review_detail",
4411
+ description:
4412
+ "Get the App Store review contact details and notes for a version (GET /appStoreVersions/{id}/appStoreReviewDetail): contact name/email/phone, demo account, review notes.",
4413
+ inputSchema: {
4414
+ type: "object",
4415
+ properties: { versionId: { type: "string" } },
4416
+ required: ["versionId"],
4417
+ },
4418
+ run: async (a) => {
4419
+ try {
4420
+ return await client.get(`/appStoreVersions/${a.versionId}/appStoreReviewDetail`);
4421
+ } catch (e) {
4422
+ if (e.status === 404) return { note: "No review detail set for this version yet.", versionId: a.versionId };
4423
+ throw e;
4424
+ }
4425
+ },
4426
+ },
4427
+ {
4428
+ name: "get_beta_review_status",
4429
+ description:
4430
+ "Get the TestFlight beta review status for a build (its betaAppReviewSubmission state: WAITING_FOR_REVIEW, IN_REVIEW, APPROVED, REJECTED). Pass buildId or buildNumber(+appId).",
4431
+ inputSchema: {
4432
+ type: "object",
4433
+ properties: {
4434
+ buildId: { type: "string" },
4435
+ buildNumber: { type: "string" },
4436
+ appId: { type: "string" },
4437
+ },
4438
+ },
4439
+ run: async (a) => {
4440
+ const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
4441
+ try {
4442
+ const res = await client.get(`/builds/${build.id}/betaAppReviewSubmission`);
4443
+ return { buildId: build.id, buildNumber: build.attributes?.version, betaReviewState: res.data?.attributes?.betaReviewState };
4444
+ } catch (e) {
4445
+ if (e.status === 404)
4446
+ return { buildId: build.id, betaReviewState: null, note: "No beta review submission for this build." };
4447
+ throw e;
4448
+ }
4449
+ },
4450
+ },
4451
+ {
4452
+ name: "set_beta_build_notes",
4453
+ description:
4454
+ "Set the TestFlight \"What to Test\" notes for a build in a locale (betaBuildLocalizations — upserts the locale's whatsNew). Pass buildId or buildNumber(+appId).",
4455
+ inputSchema: {
4456
+ type: "object",
4457
+ properties: {
4458
+ buildId: { type: "string" },
4459
+ buildNumber: { type: "string" },
4460
+ appId: { type: "string" },
4461
+ locale: { type: "string", description: "e.g. 'en-US' (default)" },
4462
+ whatsNew: { type: "string", description: "The 'What to Test' text" },
4463
+ },
4464
+ required: ["whatsNew"],
4465
+ },
4466
+ run: async (a) => {
4467
+ const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
4468
+ const locale = a.locale || "en-US";
4469
+ const existing = await client.getAll(`/builds/${build.id}/betaBuildLocalizations`);
4470
+ const row = existing.find((x) => x.attributes?.locale === locale);
4471
+ if (row) {
4472
+ const res = await client.patch(`/betaBuildLocalizations/${row.id}`, {
4473
+ data: { type: "betaBuildLocalizations", id: row.id, attributes: { whatsNew: a.whatsNew } },
4474
+ });
4475
+ return { buildId: build.id, locale, updated: true, id: row.id, result: res.data?.attributes };
4476
+ }
4477
+ const res = await client.post(`/betaBuildLocalizations`, {
4478
+ data: {
4479
+ type: "betaBuildLocalizations",
4480
+ attributes: { locale, whatsNew: a.whatsNew },
4481
+ relationships: { build: { data: { type: "builds", id: build.id } } },
4482
+ },
4483
+ });
4484
+ return { buildId: build.id, locale, created: true, id: res.data?.id };
4485
+ },
4486
+ },
4487
+
4488
+ // ---- Screenshots: bulk + ordering + audit ----
4489
+ {
4490
+ name: "find_incomplete_screenshots",
4491
+ description:
4492
+ "For a version, list every screenshot and preview across all locales/sizes whose assetDeliveryState.state is not COMPLETE. These silently block submission (ASC shows \"uploads in progress\"). Read-only.",
4493
+ inputSchema: {
4494
+ type: "object",
4495
+ properties: { versionId: { type: "string" } },
4496
+ required: ["versionId"],
4497
+ },
4498
+ run: async (a) => {
4499
+ const stuck = await findIncompleteAssets(a.versionId);
4500
+ return {
4501
+ versionId: a.versionId,
4502
+ incompleteScreenshots: stuck.screenshots,
4503
+ incompletePreviews: stuck.previews,
4504
+ allComplete: stuck.screenshots.length === 0 && stuck.previews.length === 0,
4505
+ };
4506
+ },
4507
+ },
4508
+ {
4509
+ name: "reorder_screenshots",
4510
+ description:
4511
+ "Set the display order of screenshots within a set (new uploads always append last, so call this after re-uploading). Pass the full ordered list of screenshot ids. PATCH /appScreenshotSets/{id}/relationships/appScreenshots.",
4512
+ inputSchema: {
4513
+ type: "object",
4514
+ properties: {
4515
+ screenshotSetId: { type: "string" },
4516
+ orderedIds: { type: "array", items: { type: "string" }, description: "All screenshot ids in the desired order" },
4517
+ },
4518
+ required: ["screenshotSetId", "orderedIds"],
4519
+ },
4520
+ run: async (a) => {
4521
+ await client.patch(`/appScreenshotSets/${a.screenshotSetId}/relationships/appScreenshots`, {
4522
+ data: a.orderedIds.map((id) => ({ type: "appScreenshots", id })),
4523
+ });
4524
+ return { screenshotSetId: a.screenshotSetId, order: a.orderedIds };
4525
+ },
4526
+ },
4527
+ {
4528
+ name: "replace_screenshots",
4529
+ description:
4530
+ "Replace all screenshots in a set: delete the existing ones, then upload the given files in order (one call instead of deleting and uploading each by hand). Provide absolute paths to PNG/JPEG files sized for the set's device type.",
4531
+ inputSchema: {
4532
+ type: "object",
4533
+ properties: {
4534
+ screenshotSetId: { type: "string" },
4535
+ filePaths: { type: "array", items: { type: "string" }, description: "Absolute paths, in display order" },
4536
+ },
4537
+ required: ["screenshotSetId", "filePaths"],
4538
+ },
4539
+ run: async (a) => {
4540
+ const existing = await client.getAll(`/appScreenshotSets/${a.screenshotSetId}/appScreenshots`);
4541
+ let deleted = 0;
4542
+ for (const s of existing) {
4543
+ await client.delete(`/appScreenshots/${s.id}`);
4544
+ deleted++;
4545
+ }
4546
+ const uploaded = [];
4547
+ for (const fp of a.filePaths) {
4548
+ if (!existsSync(fp)) return { error: `File not found: ${fp}`, deleted, uploaded };
4549
+ uploaded.push(await uploadScreenshotFile(a.screenshotSetId, fp));
4550
+ }
4551
+ // New uploads preserve insertion order already, but set it explicitly.
4552
+ if (uploaded.length > 1)
4553
+ await client.patch(`/appScreenshotSets/${a.screenshotSetId}/relationships/appScreenshots`, {
4554
+ data: uploaded.map((u) => ({ type: "appScreenshots", id: u.id })),
4555
+ });
4556
+ return { screenshotSetId: a.screenshotSetId, deleted, uploaded };
4557
+ },
4558
+ },
4559
+
4560
+ // ---- Territories ----
4561
+ {
4562
+ name: "list_app_territories",
4563
+ description:
4564
+ "Compact list of the territories an app is available in, plus whether new territories are added automatically. Summarizes appAvailabilityV2 (which is huge to dump raw).",
4565
+ inputSchema: {
4566
+ type: "object",
4567
+ properties: { appId: { type: "string" } },
4568
+ required: ["appId"],
4569
+ },
4570
+ run: async (a) => {
4571
+ try {
4572
+ const { data, included } = await client.getAllPages(
4573
+ `/apps/${a.appId}/appAvailabilityV2`,
4574
+ { include: "territoryAvailabilities", "limit[territoryAvailabilities]": 200 },
4575
+ );
4576
+ const root = Array.isArray(data) ? data[0] : data;
4577
+ const avail = included.filter((i) => i.type === "territoryAvailabilities");
4578
+ const codes = avail
4579
+ .filter((t) => t.attributes?.available !== false)
4580
+ .map((t) => t.relationships?.territory?.data?.id)
4581
+ .filter(Boolean)
4582
+ .sort();
4583
+ return {
4584
+ appId: a.appId,
4585
+ availableInNewTerritories: root?.attributes?.availableInNewTerritories,
4586
+ territoryCount: codes.length,
4587
+ territories: codes,
4588
+ };
4589
+ } catch (e) {
4590
+ // Fallback to the older availableTerritories relationship.
4591
+ const data = await client.getAll(`/apps/${a.appId}/availableTerritories`, { limit: 200 });
4592
+ const codes = data.map((t) => t.id).sort();
4593
+ return { appId: a.appId, territoryCount: codes.length, territories: codes, note: "via availableTerritories" };
4594
+ }
4595
+ },
4596
+ },
4597
+
4598
+ // ---- Subscriptions (groups, subscriptions, offers) ----
4599
+ {
4600
+ name: "list_subscription_groups",
4601
+ description:
4602
+ "List an app's subscription groups (referenceName + id). Use a group id with list_subscriptions.",
4603
+ inputSchema: {
4604
+ type: "object",
4605
+ properties: { appId: { type: "string" } },
4606
+ required: ["appId"],
4607
+ },
4608
+ run: async (a) => {
4609
+ const data = await client.getAll(`/apps/${a.appId}/subscriptionGroups`, { limit: 200 });
4610
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
4611
+ },
4612
+ },
4613
+ {
4614
+ name: "list_subscriptions",
4615
+ description:
4616
+ "List the subscriptions in a subscription group (name, productId, state, subscriptionPeriod).",
4617
+ inputSchema: {
4618
+ type: "object",
4619
+ properties: { groupId: { type: "string" } },
4620
+ required: ["groupId"],
4621
+ },
4622
+ run: async (a) => {
4623
+ const data = await client.getAll(`/subscriptionGroups/${a.groupId}/subscriptions`, { limit: 200 });
4624
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
4625
+ },
4626
+ },
4627
+ {
4628
+ name: "list_subscription_offers",
4629
+ description:
4630
+ "List a subscription's introductory, promotional and win-back offers, and FLAG any whose active date ranges OVERLAP — overlapping offers make StoreKit drop the product (seen as a sandbox countMismatch). Read-only.",
4631
+ inputSchema: {
4632
+ type: "object",
4633
+ properties: { subscriptionId: { type: "string" } },
4634
+ required: ["subscriptionId"],
4635
+ },
4636
+ run: async (a) => {
4637
+ const kinds = [
4638
+ ["introductory", "introductoryOffers"],
4639
+ ["promotional", "promotionalOffers"],
4640
+ ["winBack", "winBackOffers"],
4641
+ ];
4642
+ const offers = [];
4643
+ for (const [kind, rel] of kinds) {
4644
+ try {
4645
+ const data = await client.getAll(`/subscriptions/${a.subscriptionId}/${rel}`, { limit: 200 });
4646
+ for (const o of data)
4647
+ offers.push({ id: o.id, kind, ...o.attributes });
4648
+ } catch {
4649
+ /* some offer kinds may be unavailable */
4650
+ }
4651
+ }
4652
+ const overlaps = detectOverlappingOffers(offers);
4653
+ return {
4654
+ subscriptionId: a.subscriptionId,
4655
+ offerCount: offers.length,
4656
+ offers,
4657
+ overlappingOffers: overlaps,
4658
+ warning: overlaps.length
4659
+ ? `${overlaps.length} overlapping offer pair(s) — this can make StoreKit drop the product in sandbox. Remove or re-date the overlaps.`
4660
+ : null,
4661
+ };
4662
+ },
4663
+ },
4664
+ {
4665
+ name: "create_subscription_group",
4666
+ description:
4667
+ "Create a subscription group for an app (POST /subscriptionGroups). referenceName is internal only. Add subscriptions with create_subscription.",
4668
+ inputSchema: {
4669
+ type: "object",
4670
+ properties: {
4671
+ appId: { type: "string" },
4672
+ referenceName: { type: "string" },
4673
+ },
4674
+ required: ["appId", "referenceName"],
4675
+ },
4676
+ run: async (a) =>
4677
+ client.post(`/subscriptionGroups`, {
4678
+ data: {
4679
+ type: "subscriptionGroups",
4680
+ attributes: { referenceName: a.referenceName },
4681
+ relationships: { app: { data: { type: "apps", id: a.appId } } },
4682
+ },
4683
+ }),
4684
+ },
4685
+ {
4686
+ name: "create_subscription",
4687
+ description:
4688
+ "Create an auto-renewable subscription in a group (POST /subscriptions). subscriptionPeriod: ONE_WEEK, ONE_MONTH, TWO_MONTHS, THREE_MONTHS, SIX_MONTHS, ONE_YEAR. productId must be globally unique. Localizations, prices (see apply_ppp_prices) and review screenshot are separate steps. NOTE: a first-time subscription must be ticked for review on the version page — the API can't submit it.",
4689
+ inputSchema: {
4690
+ type: "object",
4691
+ properties: {
4692
+ groupId: { type: "string" },
4693
+ name: { type: "string", description: "Reference name (internal)" },
4694
+ productId: { type: "string" },
4695
+ subscriptionPeriod: { type: "string" },
4696
+ groupLevel: { type: "number", description: "Rank within the group (default 1)" },
4697
+ familySharable: { type: "boolean" },
4698
+ },
4699
+ required: ["groupId", "name", "productId", "subscriptionPeriod"],
4700
+ },
4701
+ run: async (a) => {
4702
+ const attributes = {
4703
+ name: a.name,
4704
+ productId: a.productId,
4705
+ subscriptionPeriod: a.subscriptionPeriod,
4706
+ groupLevel: a.groupLevel ?? 1,
4707
+ };
4708
+ if (a.familySharable !== undefined) attributes.familySharable = a.familySharable;
4709
+ return client.post(`/subscriptions`, {
4710
+ data: {
4711
+ type: "subscriptions",
4712
+ attributes,
4713
+ relationships: { group: { data: { type: "subscriptionGroups", id: a.groupId } } },
4714
+ },
4715
+ });
4716
+ },
4717
+ },
4718
+ {
4719
+ name: "create_in_app_purchase",
4720
+ description:
4721
+ "Create a consumable / non-consumable / non-renewing IAP (POST /v2/inAppPurchases). inAppPurchaseType: CONSUMABLE, NON_CONSUMABLE, NON_RENEWING_SUBSCRIPTION. productId must be globally unique. Optionally sets the default-locale name/description. Pricing (set_app_price / apply_ppp_prices) and review submission are separate; a first-time IAP must be ticked for review on the version page.",
4722
+ inputSchema: {
4723
+ type: "object",
4724
+ properties: {
4725
+ appId: { type: "string" },
4726
+ name: { type: "string", description: "Reference name" },
4727
+ productId: { type: "string" },
4728
+ inAppPurchaseType: { type: "string" },
4729
+ locale: { type: "string", description: "Optional: create a localization in this locale" },
4730
+ displayName: { type: "string", description: "Customer-facing name (with locale)" },
4731
+ description: { type: "string", description: "Customer-facing description (with locale)" },
4732
+ familySharable: { type: "boolean" },
4733
+ },
4734
+ required: ["appId", "name", "productId", "inAppPurchaseType"],
4735
+ },
4736
+ run: async (a) => {
4737
+ const attributes = { name: a.name, productId: a.productId, inAppPurchaseType: a.inAppPurchaseType };
4738
+ if (a.familySharable !== undefined) attributes.familySharable = a.familySharable;
4739
+ const created = await client.post(`/inAppPurchases`, {
4740
+ data: {
4741
+ type: "inAppPurchases",
4742
+ attributes,
4743
+ relationships: { app: { data: { type: "apps", id: a.appId } } },
4744
+ },
4745
+ });
4746
+ const iapId = created.data?.id;
4747
+ let localization = null;
4748
+ if (a.locale && (a.displayName || a.description)) {
4749
+ const locAttrs = { locale: a.locale };
4750
+ if (a.displayName) locAttrs.name = a.displayName;
4751
+ if (a.description) locAttrs.description = a.description;
4752
+ localization = await client.post(`/inAppPurchaseLocalizations`, {
4753
+ data: {
4754
+ type: "inAppPurchaseLocalizations",
4755
+ attributes: locAttrs,
4756
+ relationships: { inAppPurchaseV2: { data: { type: "inAppPurchases", id: iapId } } },
4757
+ },
4758
+ });
4759
+ }
4760
+ return {
4761
+ id: iapId,
4762
+ productId: a.productId,
4763
+ type: a.inAppPurchaseType,
4764
+ localization: localization?.data?.id || null,
4765
+ note: "Set a price (set_app_price / apply_ppp_prices) and tick the IAP for review on the version page — the API can't submit a first-time IAP.",
4766
+ };
4767
+ },
4768
+ },
4769
+
4770
+ // ---- Diagnostics & orchestrators ----
4771
+ {
4772
+ name: "diagnose_submission",
4773
+ description:
4774
+ "Read-only pre-submit check for a version: is a build attached and VALID, is export compliance set on it, is usesIdfa set, and are any screenshots/previews still processing. Returns the exact blockers that would cause submit_for_review's opaque 409.",
4775
+ inputSchema: {
4776
+ type: "object",
4777
+ properties: {
4778
+ appId: { type: "string" },
4779
+ versionId: { type: "string" },
4780
+ },
4781
+ required: ["appId", "versionId"],
4782
+ },
4783
+ run: async (a) => {
4784
+ const issues = await diagnoseSubmitBlockers(a.appId, a.versionId);
4785
+ return { versionId: a.versionId, ready: issues.length === 0, blockers: issues };
4786
+ },
4787
+ },
4788
+ {
4789
+ name: "swap_build",
4790
+ description:
4791
+ "Swap the build on a version in one action: (1) wait for the new build to finish processing, (2) cancel the current in-flight review submission if there is one, (3) attach the new build to the version. Reports any IAPs/subscriptions that may need re-selecting. NOTE: changes submission state — confirm with the user first.",
4792
+ inputSchema: {
4793
+ type: "object",
4794
+ properties: {
4795
+ appId: { type: "string" },
4796
+ versionId: { type: "string" },
4797
+ buildNumber: { type: "string", description: "New build number (or buildId)" },
4798
+ buildId: { type: "string" },
4799
+ waitTimeoutSeconds: { type: "number", description: "Max wait for processing (default 600)" },
4800
+ },
4801
+ required: ["appId", "versionId"],
4802
+ },
4803
+ run: async (a) => {
4804
+ const steps = {};
4805
+ // 1. Ensure the build is processed.
4806
+ const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
4807
+ if (build.attributes?.processingState !== "VALID") {
4808
+ const timeout = (a.waitTimeoutSeconds ?? 600) * 1000;
4809
+ const deadline = Date.now() + timeout;
4810
+ let state = build.attributes?.processingState;
4811
+ while (Date.now() < deadline && !["VALID", "INVALID", "FAILED"].includes(state)) {
4812
+ await new Promise((r) => setTimeout(r, 15000));
4813
+ const cur = await resolveBuild(a.appId, build.id);
4814
+ state = cur.attributes?.processingState;
4815
+ }
4816
+ steps.processing = state;
4817
+ if (state !== "VALID")
4818
+ return { ok: false, failedStep: "wait_for_processing", processingState: state, ...steps };
4819
+ } else steps.processing = "VALID";
4820
+
4821
+ // 2. Cancel any in-flight review submission.
4822
+ try {
4823
+ const { data } = await client.getAllPages(`/reviewSubmissions`, { "filter[app]": a.appId });
4824
+ const active = data.find((s) =>
4825
+ ["READY_FOR_REVIEW", "WAITING_FOR_REVIEW", "IN_REVIEW", "UNRESOLVED_ISSUES"].includes(
4826
+ s.attributes?.state,
4827
+ ),
4828
+ );
4829
+ if (active) {
4830
+ await client.patch(`/reviewSubmissions/${active.id}`, {
4831
+ data: { type: "reviewSubmissions", id: active.id, attributes: { canceled: true } },
4832
+ });
4833
+ steps.canceledSubmission = active.id;
4834
+ } else steps.canceledSubmission = null;
4835
+ } catch (e) {
4836
+ steps.cancelError = e.message;
4837
+ }
4838
+
4839
+ // 3. Attach.
4840
+ await client.patch(`/appStoreVersions/${a.versionId}/relationships/build`, {
4841
+ data: { type: "builds", id: build.id },
4842
+ });
4843
+ steps.attachedBuild = { id: build.id, number: build.attributes?.version };
4844
+
4845
+ return {
4846
+ ok: true,
4847
+ ...steps,
4848
+ reminder:
4849
+ "After swapping a build, re-confirm any in-app purchases attached to the version and re-run diagnose_submission before submitting.",
4850
+ };
4851
+ },
4852
+ },
4853
+ {
4854
+ name: "release_pipeline",
4855
+ description:
4856
+ "Run the repeated pre-submit sequence for a version: optionally attach a build, run diagnose_submission, and (only if there are no blockers and submit:true) submit for review. Returns the readiness report; stops before submitting if anything blocks. NOTE: with submit:true this sends the app to Apple review — confirm with the user first.",
4857
+ inputSchema: {
4858
+ type: "object",
4859
+ properties: {
4860
+ appId: { type: "string" },
4861
+ versionId: { type: "string" },
4862
+ buildNumber: { type: "string", description: "Optional: attach this build first" },
4863
+ buildId: { type: "string" },
4864
+ submit: { type: "boolean", description: "Submit for review if there are no blockers (default false)" },
4865
+ },
4866
+ required: ["appId", "versionId"],
4867
+ },
4868
+ run: async (a) => {
4869
+ const out = {};
4870
+ if (a.buildId || a.buildNumber) {
4871
+ const build = await resolveBuild(a.appId, a.buildId || a.buildNumber);
4872
+ await client.patch(`/appStoreVersions/${a.versionId}/relationships/build`, {
4873
+ data: { type: "builds", id: build.id },
4874
+ });
4875
+ out.attachedBuild = { id: build.id, number: build.attributes?.version };
4876
+ }
4877
+ const blockers = await diagnoseSubmitBlockers(a.appId, a.versionId);
4878
+ out.blockers = blockers;
4879
+ out.ready = blockers.length === 0;
4880
+ if (!out.ready) {
4881
+ out.submitted = false;
4882
+ out.note = "Blockers found — not submitting. Fix them and re-run.";
4883
+ return out;
4884
+ }
4885
+ if (!a.submit) {
4886
+ out.submitted = false;
4887
+ out.note = "Ready. Re-run with submit:true (after user confirmation) to submit for review.";
4888
+ return out;
4889
+ }
4890
+ const sub = await client.post("/reviewSubmissions", {
4891
+ data: {
4892
+ type: "reviewSubmissions",
4893
+ attributes: { platform: "IOS" },
4894
+ relationships: { app: { data: { type: "apps", id: a.appId } } },
4895
+ },
4896
+ });
4897
+ const subId = sub.data.id;
4898
+ await client.post("/reviewSubmissionItems", {
4899
+ data: {
4900
+ type: "reviewSubmissionItems",
4901
+ relationships: {
4902
+ reviewSubmission: { data: { type: "reviewSubmissions", id: subId } },
4903
+ appStoreVersion: { data: { type: "appStoreVersions", id: a.versionId } },
4904
+ },
4905
+ },
4906
+ });
4907
+ await client.patch(`/reviewSubmissions/${subId}`, {
4908
+ data: { type: "reviewSubmissions", id: subId, attributes: { submitted: true } },
4909
+ });
4910
+ out.submitted = true;
4911
+ out.reviewSubmissionId = subId;
4912
+ return out;
4913
+ },
4914
+ },
4915
+ {
4916
+ name: "bulk_upsert_localizations",
4917
+ description:
4918
+ "Upsert listing text across many locales in one call. `localizations` maps locale → fields. Version-localization fields: description, keywords, promotionalText, whatsNew, marketingUrl, supportUrl. App-info fields: name, subtitle, privacyPolicyUrl. Creates missing locales and updates existing ones, for BOTH the version and the appInfo. Whitespace (incl. non-breaking spaces) is sent verbatim. Set dryRun:true to see what would change.",
4919
+ inputSchema: {
4920
+ type: "object",
4921
+ properties: {
4922
+ versionId: { type: "string" },
4923
+ localizations: {
4924
+ type: "object",
4925
+ description: "{ \"fr-FR\": { name, subtitle, description, keywords, whatsNew, ... }, ... }",
4926
+ additionalProperties: { type: "object" },
4927
+ },
4928
+ dryRun: { type: "boolean" },
4929
+ },
4930
+ required: ["versionId", "localizations"],
4931
+ },
4932
+ run: async (a) => {
4933
+ const VERSION_FIELDS = ["description", "keywords", "promotionalText", "whatsNew", "marketingUrl", "supportUrl"];
4934
+ const APPINFO_FIELDS = ["name", "subtitle", "privacyPolicyUrl", "privacyPolicyText"];
4935
+ // Resolve appId + appInfo for the appInfo-side upserts.
4936
+ const v = await client.get(`/appStoreVersions/${a.versionId}`, { include: "app" });
4937
+ const appId = v.data?.relationships?.app?.data?.id;
4938
+ const existingVerLocs = await client.getAll(
4939
+ `/appStoreVersions/${a.versionId}/appStoreVersionLocalizations`,
4940
+ );
4941
+ let appInfoId = null;
4942
+ let existingInfoLocs = [];
4943
+ if (appId) {
4944
+ const infos = await client.getAll(`/apps/${appId}/appInfos`);
4945
+ if (infos.length) {
4946
+ appInfoId = infos[0].id;
4947
+ existingInfoLocs = await client.getAll(`/appInfos/${appInfoId}/appInfoLocalizations`);
4948
+ }
4949
+ }
4950
+
4951
+ const plan = [];
4952
+ for (const [locale, fields] of Object.entries(a.localizations)) {
4953
+ const verAttrs = {};
4954
+ const infoAttrs = {};
4955
+ for (const [k, val] of Object.entries(fields)) {
4956
+ if (VERSION_FIELDS.includes(k)) verAttrs[k] = val;
4957
+ else if (APPINFO_FIELDS.includes(k)) infoAttrs[k] = val;
4958
+ }
4959
+ if (Object.keys(verAttrs).length) {
4960
+ const row = existingVerLocs.find((x) => x.attributes?.locale === locale);
4961
+ plan.push({ kind: "version", locale, op: row ? "update" : "create", id: row?.id, attrs: verAttrs });
4962
+ }
4963
+ if (Object.keys(infoAttrs).length && appInfoId) {
4964
+ const row = existingInfoLocs.find((x) => x.attributes?.locale === locale);
4965
+ plan.push({ kind: "appInfo", locale, op: row ? "update" : "create", id: row?.id, attrs: infoAttrs });
4966
+ }
4967
+ }
4968
+
4969
+ if (a.dryRun) return { dryRun: true, versionId: a.versionId, appInfoId, plan };
4970
+
4971
+ const results = [];
4972
+ for (const step of plan) {
4973
+ try {
4974
+ if (step.kind === "version") {
4975
+ if (step.op === "update")
4976
+ await client.patch(`/appStoreVersionLocalizations/${step.id}`, {
4977
+ data: { type: "appStoreVersionLocalizations", id: step.id, attributes: step.attrs },
4978
+ });
4979
+ else
4980
+ await client.post(`/appStoreVersionLocalizations`, {
4981
+ data: {
4982
+ type: "appStoreVersionLocalizations",
4983
+ attributes: { locale: step.locale, ...step.attrs },
4984
+ relationships: { appStoreVersion: { data: { type: "appStoreVersions", id: a.versionId } } },
4985
+ },
4986
+ });
4987
+ } else {
4988
+ if (step.op === "update")
4989
+ await client.patch(`/appInfoLocalizations/${step.id}`, {
4990
+ data: { type: "appInfoLocalizations", id: step.id, attributes: step.attrs },
4991
+ });
4992
+ else
4993
+ await client.post(`/appInfoLocalizations`, {
4994
+ data: {
4995
+ type: "appInfoLocalizations",
4996
+ attributes: { locale: step.locale, ...step.attrs },
4997
+ relationships: { appInfo: { data: { type: "appInfos", id: appInfoId } } },
4998
+ },
4999
+ });
5000
+ }
5001
+ results.push({ ...step, ok: true, attrs: undefined });
5002
+ } catch (e) {
5003
+ results.push({ kind: step.kind, locale: step.locale, op: step.op, ok: false, error: e.message });
5004
+ }
5005
+ }
5006
+ return {
5007
+ versionId: a.versionId,
5008
+ applied: results.filter((r) => r.ok).length,
5009
+ failed: results.filter((r) => !r.ok).length,
5010
+ results,
5011
+ };
5012
+ },
5013
+ },
5014
+
5015
+ // ---- Custom Product Pages ----
5016
+ {
5017
+ name: "list_custom_product_pages",
5018
+ description:
5019
+ "List an app's Custom Product Pages (alternate product pages with their own screenshots/text, each reachable by a unique marketing URL). Returns id, name, url, visible.",
5020
+ inputSchema: { type: "object", properties: { appId: { type: "string" } }, required: ["appId"] },
5021
+ run: async (a) => {
5022
+ const data = await client.getAll(`/apps/${a.appId}/appCustomProductPages`, { limit: 200 });
5023
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
5024
+ },
5025
+ },
5026
+ {
5027
+ name: "get_custom_product_page",
5028
+ description: "Get one Custom Product Page with its versions.",
5029
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
5030
+ run: async (a) =>
5031
+ client.get(`/appCustomProductPages/${a.id}`, { include: "appCustomProductPageVersions" }),
5032
+ },
5033
+ {
5034
+ name: "create_custom_product_page",
5035
+ description:
5036
+ "Create a Custom Product Page for an app (just a name + the app). Then add a version with create_custom_product_page_version and locale copy with create_custom_product_page_localization, and screenshots via create_screenshot_set(customProductPageLocalizationId). Submit for review via the review-submission tools.",
5037
+ inputSchema: {
5038
+ type: "object",
5039
+ properties: { appId: { type: "string" }, name: { type: "string" } },
5040
+ required: ["appId", "name"],
5041
+ },
5042
+ run: async (a) =>
5043
+ client.post(`/appCustomProductPages`, {
5044
+ data: {
5045
+ type: "appCustomProductPages",
5046
+ attributes: { name: a.name },
5047
+ relationships: { app: { data: { type: "apps", id: a.appId } } },
5048
+ },
5049
+ }),
5050
+ },
5051
+ {
5052
+ name: "list_custom_product_page_versions",
5053
+ description: "List the versions of a Custom Product Page (id, version, state).",
5054
+ inputSchema: { type: "object", properties: { customProductPageId: { type: "string" } }, required: ["customProductPageId"] },
5055
+ run: async (a) => {
5056
+ const data = await client.getAll(`/appCustomProductPages/${a.customProductPageId}/appCustomProductPageVersions`);
5057
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
5058
+ },
5059
+ },
5060
+ {
5061
+ name: "create_custom_product_page_version",
5062
+ description: "Create a new version of a Custom Product Page (optionally with a deepLink).",
5063
+ inputSchema: {
5064
+ type: "object",
5065
+ properties: { customProductPageId: { type: "string" }, deepLink: { type: "string" } },
5066
+ required: ["customProductPageId"],
5067
+ },
5068
+ run: async (a) => {
5069
+ const attributes = {};
5070
+ if (a.deepLink) attributes.deepLink = a.deepLink;
5071
+ return client.post(`/appCustomProductPageVersions`, {
5072
+ data: {
5073
+ type: "appCustomProductPageVersions",
5074
+ attributes,
5075
+ relationships: { appCustomProductPage: { data: { type: "appCustomProductPages", id: a.customProductPageId } } },
5076
+ },
5077
+ });
5078
+ },
5079
+ },
5080
+ {
5081
+ name: "list_custom_product_page_localizations",
5082
+ description: "List the per-locale copy of a Custom Product Page version (locale, promotionalText).",
5083
+ inputSchema: { type: "object", properties: { versionId: { type: "string" } }, required: ["versionId"] },
5084
+ run: async (a) => {
5085
+ const data = await client.getAll(`/appCustomProductPageVersions/${a.versionId}/appCustomProductPageLocalizations`);
5086
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
5087
+ },
5088
+ },
5089
+ {
5090
+ name: "create_custom_product_page_localization",
5091
+ description: "Add a locale's copy (promotionalText) to a Custom Product Page version. Then attach screenshots with create_screenshot_set(customProductPageLocalizationId).",
5092
+ inputSchema: {
5093
+ type: "object",
5094
+ properties: {
5095
+ versionId: { type: "string" },
5096
+ locale: { type: "string", description: "e.g. en-US" },
5097
+ promotionalText: { type: "string" },
5098
+ },
5099
+ required: ["versionId", "locale"],
5100
+ },
5101
+ run: async (a) => {
5102
+ const attributes = { locale: a.locale };
5103
+ if (a.promotionalText !== undefined) attributes.promotionalText = a.promotionalText;
5104
+ return client.post(`/appCustomProductPageLocalizations`, {
5105
+ data: {
5106
+ type: "appCustomProductPageLocalizations",
5107
+ attributes,
5108
+ relationships: { appCustomProductPageVersion: { data: { type: "appCustomProductPageVersions", id: a.versionId } } },
5109
+ },
5110
+ });
5111
+ },
5112
+ },
5113
+ {
5114
+ name: "update_custom_product_page_localization",
5115
+ description: "Update the promotionalText of a Custom Product Page localization.",
5116
+ inputSchema: {
5117
+ type: "object",
5118
+ properties: { localizationId: { type: "string" }, promotionalText: { type: "string" } },
5119
+ required: ["localizationId", "promotionalText"],
5120
+ },
5121
+ run: async (a) =>
5122
+ client.patch(`/appCustomProductPageLocalizations/${a.localizationId}`, {
5123
+ data: { type: "appCustomProductPageLocalizations", id: a.localizationId, attributes: { promotionalText: a.promotionalText } },
5124
+ }),
5125
+ },
5126
+
5127
+ // ---- Product Page Optimization (appStoreVersionExperiments v2) ----
5128
+ {
5129
+ name: "list_ppo_experiments",
5130
+ description:
5131
+ "List Product Page Optimization A/B experiments (v2) for an App Store version. Returns id, name, state, trafficProportion, start/end dates.",
5132
+ inputSchema: { type: "object", properties: { versionId: { type: "string", description: "appStoreVersion id" } }, required: ["versionId"] },
5133
+ run: async (a) => {
5134
+ const data = await client.getAll(`/appStoreVersions/${a.versionId}/appStoreVersionExperimentsV2`);
5135
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
5136
+ },
5137
+ },
5138
+ {
5139
+ name: "get_ppo_experiment",
5140
+ description:
5141
+ "Get one PPO experiment (v2) with its treatments. NOTE: experiment RESULTS (conversion %, confidence) are not exposed by the App Store Connect API — view those in the App Store Connect web UI.",
5142
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
5143
+ run: async (a) =>
5144
+ client.get(`/v2/appStoreVersionExperiments/${a.id}`, { include: "appStoreVersionExperimentTreatments" }),
5145
+ },
5146
+ {
5147
+ name: "create_ppo_experiment",
5148
+ description:
5149
+ "Create a Product Page Optimization experiment (v2) on an app. trafficProportion is the percentage of traffic in the test (e.g. 66). Only one DRAFT experiment per app at a time. Then add treatments (create_experiment_treatment) and start it (start_ppo_experiment).",
5150
+ inputSchema: {
5151
+ type: "object",
5152
+ properties: {
5153
+ appId: { type: "string" },
5154
+ name: { type: "string" },
5155
+ trafficProportion: { type: "number", description: "Percent of traffic in the experiment, e.g. 66" },
5156
+ platform: { type: "string", description: "IOS (default), MAC_OS, TV_OS" },
5157
+ },
5158
+ required: ["appId", "name", "trafficProportion"],
5159
+ },
5160
+ run: async (a) =>
5161
+ client.post(`/v2/appStoreVersionExperiments`, {
5162
+ data: {
5163
+ type: "appStoreVersionExperiments",
5164
+ attributes: { name: a.name, platform: a.platform || "IOS", trafficProportion: a.trafficProportion },
5165
+ relationships: { app: { data: { type: "apps", id: a.appId } } },
5166
+ },
5167
+ }),
5168
+ },
5169
+ {
5170
+ name: "start_ppo_experiment",
5171
+ description: "Start a PPO experiment (v2). Sends it toward review/running. NOTE: this begins an A/B test shown to real users — confirm first.",
5172
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
5173
+ run: async (a) =>
5174
+ client.patch(`/v2/appStoreVersionExperiments/${a.id}`, {
5175
+ data: { type: "appStoreVersionExperiments", id: a.id, attributes: { started: true } },
5176
+ }),
5177
+ },
5178
+ {
5179
+ name: "stop_ppo_experiment",
5180
+ description: "Stop/halt a running PPO experiment (v2). Halting preserves readable results (in the web UI); it does not delete the experiment.",
5181
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
5182
+ run: async (a) =>
5183
+ client.patch(`/v2/appStoreVersionExperiments/${a.id}`, {
5184
+ data: { type: "appStoreVersionExperiments", id: a.id, attributes: { started: false } },
5185
+ }),
5186
+ },
5187
+ {
5188
+ name: "list_experiment_treatments",
5189
+ description: "List the treatments (variants) of a PPO experiment (v2).",
5190
+ inputSchema: { type: "object", properties: { experimentId: { type: "string" } }, required: ["experimentId"] },
5191
+ run: async (a) => {
5192
+ const data = await client.getAll(`/v2/appStoreVersionExperiments/${a.experimentId}/appStoreVersionExperimentTreatments`);
5193
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
5194
+ },
5195
+ },
5196
+ {
5197
+ name: "create_experiment_treatment",
5198
+ description: "Add a treatment (variant) to a PPO experiment (v2). appIconName optionally tests an alternate app icon. Add locale copy with create_experiment_treatment_localization and screenshots via create_screenshot_set(treatmentLocalizationId).",
5199
+ inputSchema: {
5200
+ type: "object",
5201
+ properties: {
5202
+ experimentId: { type: "string" },
5203
+ name: { type: "string" },
5204
+ appIconName: { type: "string", description: "Optional alternate app icon asset name" },
5205
+ },
5206
+ required: ["experimentId", "name"],
5207
+ },
5208
+ run: async (a) => {
5209
+ const attributes = { name: a.name };
5210
+ if (a.appIconName) attributes.appIconName = a.appIconName;
5211
+ return client.post(`/appStoreVersionExperimentTreatments`, {
5212
+ data: {
5213
+ type: "appStoreVersionExperimentTreatments",
5214
+ attributes,
5215
+ relationships: { appStoreVersionExperimentV2: { data: { type: "appStoreVersionExperiments", id: a.experimentId } } },
5216
+ },
5217
+ });
5218
+ },
5219
+ },
5220
+ {
5221
+ name: "create_experiment_treatment_localization",
5222
+ description: "Add a locale to a PPO treatment (so you can attach localized screenshots to it).",
5223
+ inputSchema: {
5224
+ type: "object",
5225
+ properties: { treatmentId: { type: "string" }, locale: { type: "string" } },
5226
+ required: ["treatmentId", "locale"],
5227
+ },
5228
+ run: async (a) =>
5229
+ client.post(`/appStoreVersionExperimentTreatmentLocalizations`, {
5230
+ data: {
5231
+ type: "appStoreVersionExperimentTreatmentLocalizations",
5232
+ attributes: { locale: a.locale },
5233
+ relationships: { appStoreVersionExperimentTreatment: { data: { type: "appStoreVersionExperimentTreatments", id: a.treatmentId } } },
5234
+ },
5235
+ }),
5236
+ },
5237
+
5238
+ // ---- Asset Library (reusable media across pages / events) ----
5239
+ {
5240
+ name: "get_asset_library",
5241
+ description: "Get an app's Asset Library (the per-app media library). Returns its id for the list/upload tools.",
5242
+ inputSchema: { type: "object", properties: { appId: { type: "string" } }, required: ["appId"] },
5243
+ run: async (a) => client.get(`/apps/${a.appId}/assetLibrary`),
5244
+ },
5245
+ {
5246
+ name: "list_asset_library_images",
5247
+ description: "List images in an app's Asset Library. category filter: CREATIVE_ASSETS or APP_SCREENSHOTS_AND_PREVIEWS.",
5248
+ inputSchema: {
5249
+ type: "object",
5250
+ properties: { appId: { type: "string" }, category: { type: "string" } },
5251
+ required: ["appId"],
5252
+ },
5253
+ run: async (a) => {
5254
+ const lib = await client.get(`/apps/${a.appId}/assetLibrary`);
5255
+ const libId = lib.data?.id;
5256
+ const q = {};
5257
+ if (a.category) q["filter[category]"] = a.category;
5258
+ const data = await client.getAll(`/appAssetLibraries/${libId}/images`, q);
5259
+ return { assetLibraryId: libId, images: data.map((x) => ({ id: x.id, ...x.attributes })) };
5260
+ },
5261
+ },
5262
+ {
5263
+ name: "list_asset_library_videos",
5264
+ description: "List videos in an app's Asset Library.",
5265
+ inputSchema: { type: "object", properties: { appId: { type: "string" } }, required: ["appId"] },
5266
+ run: async (a) => {
5267
+ const lib = await client.get(`/apps/${a.appId}/assetLibrary`);
5268
+ const libId = lib.data?.id;
5269
+ const data = await client.getAll(`/appAssetLibraries/${libId}/videos`);
5270
+ return { assetLibraryId: libId, videos: data.map((x) => ({ id: x.id, ...x.attributes })) };
5271
+ },
5272
+ },
5273
+ {
5274
+ name: "upload_asset_library_image",
5275
+ description:
5276
+ "Upload an image into an app's Asset Library (reserve → upload → commit). category: CREATIVE_ASSETS (default) or APP_SCREENSHOTS_AND_PREVIEWS. Once uploaded, reuse it on a product page / event with assign_asset_placement.",
5277
+ inputSchema: {
5278
+ type: "object",
5279
+ properties: {
5280
+ appId: { type: "string" },
5281
+ filePath: { type: "string", description: "Absolute path to the image" },
5282
+ category: { type: "string", description: "CREATIVE_ASSETS (default) or APP_SCREENSHOTS_AND_PREVIEWS" },
5283
+ referenceName: { type: "string" },
5284
+ },
5285
+ required: ["appId", "filePath"],
5286
+ },
5287
+ run: async (a) => {
5288
+ if (!existsSync(a.filePath)) return { error: `File not found: ${a.filePath}` };
5289
+ const lib = await client.get(`/apps/${a.appId}/assetLibrary`);
5290
+ const libId = lib.data?.id;
5291
+ if (!libId) return { error: "Could not resolve the app's asset library." };
5292
+ const buf = readFileSync(a.filePath);
5293
+ const fileName = basename(a.filePath);
5294
+ const attributes = { category: a.category || "CREATIVE_ASSETS", fileName, fileSize: buf.length };
5295
+ if (a.referenceName) attributes.referenceName = a.referenceName;
5296
+ const reservation = await client.post(`/appAssetLibraryImages`, {
5297
+ data: {
5298
+ type: "appAssetLibraryImages",
5299
+ attributes,
5300
+ relationships: { assetLibrary: { data: { type: "appAssetLibraries", id: libId } } },
5301
+ },
5302
+ });
5303
+ const id = reservation.data.id;
5304
+ await client.uploadAsset(reservation.data.attributes.uploadOperations, buf);
5305
+ const committed = await client.patch(`/appAssetLibraryImages/${id}`, {
5306
+ data: { type: "appAssetLibraryImages", id, attributes: { uploaded: true, sourceFileChecksum: AppStoreConnectClient.md5(buf) } },
5307
+ });
5308
+ const at = committed?.data?.attributes || {};
5309
+ return { id, assetLibraryId: libId, fileName: at.fileName || fileName, state: at.assetState || at.state };
5310
+ },
5311
+ },
5312
+ {
5313
+ name: "assign_asset_placement",
5314
+ description:
5315
+ "Reuse an Asset Library image/video on a destination localization by creating a placement. Provide the source (imageId or videoId) and exactly ONE destination localization id. placementType examples: APP_SCREENSHOT, APP_PREVIEW, PRODUCT_PAGE_HEADER_ASSET, EVENT_CARD_ASSET, EVENT_DETAILS_PAGE_ASSET.",
5316
+ inputSchema: {
5317
+ type: "object",
5318
+ properties: {
5319
+ imageId: { type: "string" },
5320
+ videoId: { type: "string" },
5321
+ placementType: { type: "string" },
5322
+ appStoreVersionLocalizationId: { type: "string" },
5323
+ customProductPageLocalizationId: { type: "string" },
5324
+ treatmentLocalizationId: { type: "string" },
5325
+ appEventLocalizationId: { type: "string" },
5326
+ },
5327
+ },
5328
+ run: async (a) => {
5329
+ const relationships = {};
5330
+ if (a.imageId) relationships.image = { data: { type: "appAssetLibraryImages", id: a.imageId } };
5331
+ else if (a.videoId) relationships.video = { data: { type: "appAssetLibraryVideos", id: a.videoId } };
5332
+ else return { error: "Provide imageId or videoId (the source asset)." };
5333
+ if (a.appStoreVersionLocalizationId)
5334
+ relationships.appStoreVersionLocalization = { data: { type: "appStoreVersionLocalizations", id: a.appStoreVersionLocalizationId } };
5335
+ else if (a.customProductPageLocalizationId)
5336
+ relationships.appCustomProductPageLocalization = { data: { type: "appCustomProductPageLocalizations", id: a.customProductPageLocalizationId } };
5337
+ else if (a.treatmentLocalizationId)
5338
+ relationships.appStoreVersionExperimentTreatmentLocalization = { data: { type: "appStoreVersionExperimentTreatmentLocalizations", id: a.treatmentLocalizationId } };
5339
+ else if (a.appEventLocalizationId)
5340
+ relationships.appEventLocalization = { data: { type: "appEventLocalizations", id: a.appEventLocalizationId } };
5341
+ else return { error: "Provide exactly one destination localization id." };
5342
+ const attributes = {};
5343
+ if (a.placementType) attributes.placementType = a.placementType;
5344
+ return client.post(`/appAssetLibraryPlacements`, {
5345
+ data: { type: "appAssetLibraryPlacements", attributes, relationships },
5346
+ });
5347
+ },
5348
+ },
5349
+ {
5350
+ name: "delete_asset_placement",
5351
+ description: "Remove an Asset Library placement (unassign a reused asset from a localization). Does not delete the asset itself.",
5352
+ inputSchema: { type: "object", properties: { placementId: { type: "string" } }, required: ["placementId"] },
5353
+ run: async (a) => {
5354
+ await client.delete(`/appAssetLibraryPlacements/${a.placementId}`);
5355
+ return { deleted: a.placementId };
5356
+ },
5357
+ },
5358
+
5359
+ // ---- Webhooks ----
5360
+ {
5361
+ name: "list_webhooks",
5362
+ description: "List an app's webhooks (event callbacks). Returns id, name, url, enabled, eventTypes.",
5363
+ inputSchema: { type: "object", properties: { appId: { type: "string" } }, required: ["appId"] },
5364
+ run: async (a) => {
5365
+ const data = await client.getAll(`/apps/${a.appId}/webhooks`);
5366
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
5367
+ },
5368
+ },
5369
+ {
5370
+ name: "get_webhook",
5371
+ description: "Get one webhook by id.",
5372
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
5373
+ run: async (a) => client.get(`/webhooks/${a.id}`),
5374
+ },
5375
+ {
5376
+ name: "create_webhook",
5377
+ description:
5378
+ "Create a webhook for an app — App Store Connect will POST to your url when an event fires (no more polling). Up to 10 per app. Common eventTypes: APP_STORE_VERSION_APP_VERSION_STATE_UPDATED, BUILD_UPLOAD_STATE_UPDATED, BUILD_BETA_DETAIL_EXTERNAL_BUILD_STATE_UPDATED, BETA_FEEDBACK_CRASH_SUBMISSION_CREATED, BETA_FEEDBACK_SCREENSHOT_SUBMISSION_CREATED. secret is used to sign deliveries so you can verify them.",
5379
+ inputSchema: {
5380
+ type: "object",
5381
+ properties: {
5382
+ appId: { type: "string" },
5383
+ name: { type: "string" },
5384
+ url: { type: "string", description: "HTTPS endpoint to receive events" },
5385
+ eventTypes: { type: "array", items: { type: "string" } },
5386
+ secret: { type: "string", description: "Signing secret for verifying deliveries" },
5387
+ enabled: { type: "boolean", description: "Default true" },
5388
+ },
5389
+ required: ["appId", "name", "url", "eventTypes", "secret"],
5390
+ },
5391
+ run: async (a) =>
5392
+ client.post(`/webhooks`, {
5393
+ data: {
5394
+ type: "webhooks",
5395
+ attributes: { name: a.name, url: a.url, eventTypes: a.eventTypes, secret: a.secret, enabled: a.enabled !== false },
5396
+ relationships: { app: { data: { type: "apps", id: a.appId } } },
5397
+ },
5398
+ }),
5399
+ },
5400
+ {
5401
+ name: "update_webhook",
5402
+ description: "Update a webhook (name, url, eventTypes, enabled, secret). Only pass fields to change.",
5403
+ inputSchema: {
5404
+ type: "object",
5405
+ properties: {
5406
+ id: { type: "string" },
5407
+ name: { type: "string" },
5408
+ url: { type: "string" },
5409
+ eventTypes: { type: "array", items: { type: "string" } },
5410
+ enabled: { type: "boolean" },
5411
+ secret: { type: "string" },
5412
+ },
5413
+ required: ["id"],
5414
+ },
5415
+ run: async (a) => {
5416
+ const attributes = {};
5417
+ for (const k of ["name", "url", "eventTypes", "enabled", "secret"]) if (a[k] !== undefined) attributes[k] = a[k];
5418
+ if (!Object.keys(attributes).length) return { error: "No fields to update." };
5419
+ return client.patch(`/webhooks/${a.id}`, { data: { type: "webhooks", id: a.id, attributes } });
5420
+ },
5421
+ },
5422
+ {
5423
+ name: "delete_webhook",
5424
+ description: "Delete a webhook by id.",
5425
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
5426
+ run: async (a) => {
5427
+ await client.delete(`/webhooks/${a.id}`);
5428
+ return { deleted: a.id };
5429
+ },
5430
+ },
5431
+ {
5432
+ name: "list_webhook_deliveries",
5433
+ description: "List recent delivery attempts for a webhook (status, timestamps) — useful for debugging a misbehaving endpoint.",
5434
+ inputSchema: {
5435
+ type: "object",
5436
+ properties: { id: { type: "string" }, limit: { type: "number", description: "Max (default 50)" } },
5437
+ required: ["id"],
5438
+ },
5439
+ run: async (a) => {
5440
+ const data = await client.getAll(`/webhooks/${a.id}/deliveries`, { limit: a.limit ?? 50 }, 3);
5441
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
5442
+ },
5443
+ },
5444
+ {
5445
+ name: "ping_webhook",
5446
+ description: "Send a test ping to a webhook to verify your endpoint receives and accepts deliveries.",
5447
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
5448
+ run: async (a) =>
5449
+ client.post(`/webhookPings`, {
5450
+ data: { type: "webhookPings", relationships: { webhook: { data: { type: "webhooks", id: a.id } } } },
5451
+ }),
5452
+ },
5453
+
5454
+ // ---- Subscription offer codes (promo-code replacement) ----
5455
+ {
5456
+ name: "list_subscription_offer_codes",
5457
+ description: "List the offer codes defined for a subscription (id, name, offerMode, duration).",
5458
+ inputSchema: { type: "object", properties: { subscriptionId: { type: "string" } }, required: ["subscriptionId"] },
5459
+ run: async (a) => {
5460
+ const data = await client.getAll(`/subscriptions/${a.subscriptionId}/offerCodes`, { limit: 200 });
5461
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
5462
+ },
5463
+ },
5464
+ {
5465
+ name: "create_subscription_offer_code",
5466
+ description:
5467
+ "Create an offer code for a subscription (the modern replacement for promo codes, which Apple is phasing out). offerMode: PAY_AS_YOU_GO | PAY_UP_FRONT | FREE_TRIAL. duration: ONE_WEEK | ONE_MONTH | TWO_MONTHS | THREE_MONTHS | SIX_MONTHS | ONE_YEAR (etc.). customerEligibilities e.g. [\"NEW\",\"EXISTING\",\"EXPIRED\"]. offerEligibility e.g. STACK_WITH_INTRO_OFFERS. prices: one {territory, pricePointId} per territory you offer in (pricePointId from list_app_price_points-style subscription price points). Then generate codes with create_offer_code_one_time_use or create_offer_code_custom.",
5468
+ inputSchema: {
5469
+ type: "object",
5470
+ properties: {
5471
+ subscriptionId: { type: "string" },
5472
+ name: { type: "string" },
5473
+ offerMode: { type: "string" },
5474
+ duration: { type: "string" },
5475
+ numberOfPeriods: { type: "number" },
5476
+ customerEligibilities: { type: "array", items: { type: "string" } },
5477
+ offerEligibility: { type: "string" },
5478
+ autoRenewEnabled: { type: "boolean" },
5479
+ prices: {
5480
+ type: "array",
5481
+ description: "[{ territory: 'USA', pricePointId: '...' }]",
5482
+ items: { type: "object", properties: { territory: { type: "string" }, pricePointId: { type: "string" } } },
5483
+ },
5484
+ },
5485
+ required: ["subscriptionId", "name", "offerMode", "duration", "numberOfPeriods", "customerEligibilities", "offerEligibility", "prices"],
5486
+ },
5487
+ run: async (a) => {
5488
+ const priceRefs = [];
5489
+ const included = [];
5490
+ (a.prices || []).forEach((p, i) => {
5491
+ const tempId = `price${i}`;
5492
+ priceRefs.push({ type: "subscriptionOfferCodePrices", id: tempId });
5493
+ included.push({
5494
+ type: "subscriptionOfferCodePrices",
5495
+ id: tempId,
5496
+ relationships: {
5497
+ subscriptionPricePoint: { data: { type: "subscriptionPricePoints", id: p.pricePointId } },
5498
+ territory: { data: { type: "territories", id: p.territory } },
5499
+ },
5500
+ });
5501
+ });
5502
+ const attributes = {
5503
+ name: a.name,
5504
+ offerMode: a.offerMode,
5505
+ duration: a.duration,
5506
+ numberOfPeriods: a.numberOfPeriods,
5507
+ customerEligibilities: a.customerEligibilities,
5508
+ offerEligibility: a.offerEligibility,
5509
+ };
5510
+ if (a.autoRenewEnabled !== undefined) attributes.autoRenewEnabled = a.autoRenewEnabled;
5511
+ return client.post(`/subscriptionOfferCodes`, {
5512
+ data: {
5513
+ type: "subscriptionOfferCodes",
5514
+ attributes,
5515
+ relationships: {
5516
+ subscription: { data: { type: "subscriptions", id: a.subscriptionId } },
5517
+ prices: { data: priceRefs },
5518
+ },
5519
+ },
5520
+ included,
5521
+ });
5522
+ },
5523
+ },
5524
+ {
5525
+ name: "create_offer_code_one_time_use",
5526
+ description: "Generate a batch of one-time-use codes for an offer code (each code redeemable once). Returns the batch; fetch the code values from its download link.",
5527
+ inputSchema: {
5528
+ type: "object",
5529
+ properties: {
5530
+ offerCodeId: { type: "string" },
5531
+ numberOfCodes: { type: "number" },
5532
+ expirationDate: { type: "string", description: "YYYY-MM-DD" },
5533
+ environment: { type: "string", description: "PRODUCTION (default) or SANDBOX" },
5534
+ },
5535
+ required: ["offerCodeId", "numberOfCodes", "expirationDate"],
5536
+ },
5537
+ run: async (a) => {
5538
+ const attributes = { numberOfCodes: a.numberOfCodes, expirationDate: a.expirationDate };
5539
+ if (a.environment) attributes.environment = a.environment;
5540
+ return client.post(`/subscriptionOfferCodeOneTimeUseCodes`, {
5541
+ data: {
5542
+ type: "subscriptionOfferCodeOneTimeUseCodes",
5543
+ attributes,
5544
+ relationships: { offerCode: { data: { type: "subscriptionOfferCodes", id: a.offerCodeId } } },
5545
+ },
5546
+ });
5547
+ },
5548
+ },
5549
+ {
5550
+ name: "create_offer_code_custom",
5551
+ description: "Create a custom (merchant) code for an offer code — one reusable code string redeemable numberOfCodes times.",
5552
+ inputSchema: {
5553
+ type: "object",
5554
+ properties: {
5555
+ offerCodeId: { type: "string" },
5556
+ customCode: { type: "string" },
5557
+ numberOfCodes: { type: "number" },
5558
+ expirationDate: { type: "string", description: "YYYY-MM-DD (optional)" },
5559
+ },
5560
+ required: ["offerCodeId", "customCode", "numberOfCodes"],
5561
+ },
5562
+ run: async (a) => {
5563
+ const attributes = { customCode: a.customCode, numberOfCodes: a.numberOfCodes };
5564
+ if (a.expirationDate) attributes.expirationDate = a.expirationDate;
5565
+ return client.post(`/subscriptionOfferCodeCustomCodes`, {
5566
+ data: {
5567
+ type: "subscriptionOfferCodeCustomCodes",
5568
+ attributes,
5569
+ relationships: { offerCode: { data: { type: "subscriptionOfferCodes", id: a.offerCodeId } } },
5570
+ },
5571
+ });
5572
+ },
5573
+ },
5574
+ {
5575
+ name: "list_offer_code_codes",
5576
+ description: "List the generated code batches for an offer code. kind: 'oneTimeUse' (default) or 'custom'.",
5577
+ inputSchema: {
5578
+ type: "object",
5579
+ properties: { offerCodeId: { type: "string" }, kind: { type: "string", description: "oneTimeUse (default) or custom" } },
5580
+ required: ["offerCodeId"],
5581
+ },
5582
+ run: async (a) => {
5583
+ const rel = a.kind === "custom" ? "customCodes" : "oneTimeUseCodes";
5584
+ const data = await client.getAll(`/subscriptionOfferCodes/${a.offerCodeId}/${rel}`, { limit: 200 });
5585
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
5586
+ },
5587
+ },
5588
+
5589
+ // ---- In-App Events ----
5590
+ {
5591
+ name: "list_app_events",
5592
+ description: "List an app's In-App Events (promotional events on the App Store). Filter by eventState (e.g. DRAFT, READY_FOR_REVIEW, ACCEPTED, APPROVED, PUBLISHED, PAST).",
5593
+ inputSchema: {
5594
+ type: "object",
5595
+ properties: { appId: { type: "string" }, eventState: { type: "string" } },
5596
+ required: ["appId"],
5597
+ },
5598
+ run: async (a) => {
5599
+ const q = {};
5600
+ if (a.eventState) q["filter[eventState]"] = a.eventState;
5601
+ const data = await client.getAll(`/apps/${a.appId}/appEvents`, q);
5602
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
5603
+ },
5604
+ },
5605
+ {
5606
+ name: "get_app_event",
5607
+ description: "Get one In-App Event by id.",
5608
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
5609
+ run: async (a) => client.get(`/appEvents/${a.id}`),
5610
+ },
5611
+ {
5612
+ name: "create_app_event",
5613
+ description: "Create an In-App Event for an app. referenceName is internal. Add locale copy with create_app_event_localization and media via assign_asset_placement(appEventLocalizationId).",
5614
+ inputSchema: {
5615
+ type: "object",
5616
+ properties: {
5617
+ appId: { type: "string" },
5618
+ referenceName: { type: "string" },
5619
+ primaryLocale: { type: "string" },
5620
+ deepLink: { type: "string" },
5621
+ badge: { type: "string" },
5622
+ purchaseRequirement: { type: "string" },
5623
+ purpose: { type: "string" },
5624
+ priority: { type: "string" },
5625
+ },
5626
+ required: ["appId", "referenceName"],
5627
+ },
5628
+ run: async (a) => {
5629
+ const attributes = { referenceName: a.referenceName };
5630
+ for (const k of ["primaryLocale", "deepLink", "badge", "purchaseRequirement", "purpose", "priority"])
5631
+ if (a[k] !== undefined) attributes[k] = a[k];
5632
+ return client.post(`/appEvents`, {
5633
+ data: { type: "appEvents", attributes, relationships: { app: { data: { type: "apps", id: a.appId } } } },
5634
+ });
5635
+ },
5636
+ },
5637
+ {
5638
+ name: "create_app_event_localization",
5639
+ description: "Add a locale's copy (name, short/long description) to an In-App Event.",
5640
+ inputSchema: {
5641
+ type: "object",
5642
+ properties: {
5643
+ eventId: { type: "string" },
5644
+ locale: { type: "string" },
5645
+ name: { type: "string" },
5646
+ shortDescription: { type: "string" },
5647
+ longDescription: { type: "string" },
5648
+ },
5649
+ required: ["eventId", "locale"],
5650
+ },
5651
+ run: async (a) => {
5652
+ const attributes = { locale: a.locale };
5653
+ for (const k of ["name", "shortDescription", "longDescription"]) if (a[k] !== undefined) attributes[k] = a[k];
5654
+ return client.post(`/appEventLocalizations`, {
5655
+ data: { type: "appEventLocalizations", attributes, relationships: { appEvent: { data: { type: "appEvents", id: a.eventId } } } },
5656
+ });
5657
+ },
5658
+ },
5659
+
5660
+ // ---- Game Center challenges (read) ----
5661
+ {
5662
+ name: "list_game_center_challenges",
5663
+ description: "List Game Center challenges for an app (resolves the app's Game Center detail first). Read-only.",
5664
+ inputSchema: { type: "object", properties: { appId: { type: "string" } }, required: ["appId"] },
5665
+ run: async (a) => {
5666
+ const detail = await client.get(`/apps/${a.appId}/gameCenterDetail`);
5667
+ const detailId = detail.data?.id;
5668
+ if (!detailId) return { error: "This app has no Game Center detail.", appId: a.appId };
5669
+ const data = await client.getAll(`/gameCenterDetails/${detailId}/gameCenterChallenges`);
5670
+ return { gameCenterDetailId: detailId, challenges: data.map((x) => ({ id: x.id, ...x.attributes })) };
5671
+ },
5672
+ },
5673
+ {
5674
+ name: "get_game_center_challenge",
5675
+ description: "Get one Game Center challenge by id, with its versions.",
5676
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
5677
+ run: async (a) => client.get(`/gameCenterChallenges/${a.id}`, { include: "versions" }),
5678
+ },
5679
+
2943
5680
  // ---- Generic escape hatch ----
2944
5681
  {
2945
5682
  name: "raw_request",
@@ -2985,7 +5722,7 @@ Some things a great App Store listing needs live OUTSIDE App Store Connect and c
2985
5722
  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.`;
2986
5723
 
2987
5724
  const server = new Server(
2988
- { name: "appstore-api-mcp", version: "1.12.0" },
5725
+ { name: "appstore-api-mcp", version: "1.16.0" },
2989
5726
  { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS },
2990
5727
  );
2991
5728