appstore-api-mcp 1.0.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 ADDED
@@ -0,0 +1,815 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { basename } from "node:path";
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import {
7
+ CallToolRequestSchema,
8
+ ListToolsRequestSchema,
9
+ } from "@modelcontextprotocol/sdk/types.js";
10
+ import { AppStoreConnectClient } from "./client.js";
11
+
12
+ const client = new AppStoreConnectClient({
13
+ keyId: process.env.ASC_KEY_ID,
14
+ issuerId: process.env.ASC_ISSUER_ID,
15
+ privateKeyPath: process.env.ASC_PRIVATE_KEY_PATH,
16
+ privateKey: process.env.ASC_PRIVATE_KEY,
17
+ privateKeyBase64: process.env.ASC_PRIVATE_KEY_BASE64,
18
+ });
19
+
20
+ const ok = (data) => ({
21
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
22
+ });
23
+ const fail = (e) => ({
24
+ content: [{ type: "text", text: `Error: ${e.message}` }],
25
+ isError: true,
26
+ });
27
+
28
+ // ---- Shared helpers (validation, diff, concurrency) -------------------------
29
+
30
+ // Apple's documented length limits for editable metadata fields.
31
+ const LIMITS = {
32
+ name: 30,
33
+ subtitle: 30,
34
+ keywords: 100,
35
+ promotionalText: 170,
36
+ description: 4000,
37
+ whatsNew: 4000,
38
+ };
39
+
40
+ /** Warn about fields that exceed Apple's limits. Non-blocking. */
41
+ function validateAttributes(attributes) {
42
+ const warnings = [];
43
+ for (const [field, value] of Object.entries(attributes)) {
44
+ const limit = LIMITS[field];
45
+ if (limit && typeof value === "string" && value.length > limit) {
46
+ warnings.push(
47
+ `'${field}' is ${value.length} chars — exceeds Apple's limit of ${limit}.`,
48
+ );
49
+ }
50
+ }
51
+ return warnings;
52
+ }
53
+
54
+ /**
55
+ * Build a field-by-field diff between current attributes and proposed changes,
56
+ * including length/limit info. Used by dry-run mode.
57
+ */
58
+ function buildDiff(current = {}, attributes) {
59
+ const changes = [];
60
+ for (const [field, to] of Object.entries(attributes)) {
61
+ const from = current[field] ?? null;
62
+ const limit = LIMITS[field];
63
+ changes.push({
64
+ field,
65
+ from,
66
+ to,
67
+ changed: from !== to,
68
+ ...(limit
69
+ ? {
70
+ newLength: typeof to === "string" ? to.length : null,
71
+ limit,
72
+ exceedsLimit:
73
+ typeof to === "string" ? to.length > limit : false,
74
+ }
75
+ : {}),
76
+ });
77
+ }
78
+ return changes;
79
+ }
80
+
81
+ /**
82
+ * Dry-run vs apply for an update. When dryRun is true, fetch current values,
83
+ * return a diff + warnings, and write nothing. Otherwise PATCH and return the
84
+ * result (with any validation warnings attached).
85
+ */
86
+ async function previewOrApply({ dryRun, fetchCurrent, attributes, apply, id }) {
87
+ const warnings = validateAttributes(attributes);
88
+ if (dryRun) {
89
+ let current = {};
90
+ try {
91
+ const res = await fetchCurrent();
92
+ current = res?.data?.attributes || {};
93
+ } catch {
94
+ /* fall back to empty current if the read fails */
95
+ }
96
+ return {
97
+ dryRun: true,
98
+ id,
99
+ changes: buildDiff(current, attributes),
100
+ warnings,
101
+ note: "No changes were written. Re-run without dryRun to apply.",
102
+ };
103
+ }
104
+ const result = await apply();
105
+ return warnings.length ? { ...result, _warnings: warnings } : result;
106
+ }
107
+
108
+ /** Run `fn` over `items` with limited concurrency, preserving order. */
109
+ async function mapLimit(items, limit, fn) {
110
+ const results = new Array(items.length);
111
+ let i = 0;
112
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
113
+ while (i < items.length) {
114
+ const idx = i++;
115
+ results[idx] = await fn(items[idx], idx);
116
+ }
117
+ });
118
+ await Promise.all(workers);
119
+ return results;
120
+ }
121
+
122
+ // App Store version states in which listing metadata is editable.
123
+ const EDITABLE_VERSION_STATES = new Set([
124
+ "PREPARE_FOR_SUBMISSION",
125
+ "DEVELOPER_REJECTED",
126
+ "REJECTED",
127
+ "METADATA_REJECTED",
128
+ "INVALID_BINARY",
129
+ ]);
130
+
131
+ // ---- Tool definitions -------------------------------------------------------
132
+
133
+ const tools = [
134
+ {
135
+ name: "list_apps",
136
+ description:
137
+ "List all apps in your App Store Connect account. Returns id, name, bundleId, sku, primaryLocale. Use the app id with the other tools.",
138
+ inputSchema: {
139
+ type: "object",
140
+ properties: {
141
+ limit: { type: "number", description: "Max apps (default 100)" },
142
+ filterBundleId: {
143
+ type: "string",
144
+ description: "Optional exact bundle id filter",
145
+ },
146
+ },
147
+ },
148
+ run: async (a) => {
149
+ const query = { limit: a.limit ?? 100 };
150
+ if (a.filterBundleId) query["filter[bundleId]"] = a.filterBundleId;
151
+ const apps = await client.getAll("/apps", query);
152
+ return apps.map((x) => ({ id: x.id, ...x.attributes }));
153
+ },
154
+ },
155
+ {
156
+ name: "get_app",
157
+ description: "Get a single app's details by its App Store Connect id.",
158
+ inputSchema: {
159
+ type: "object",
160
+ properties: { appId: { type: "string" } },
161
+ required: ["appId"],
162
+ },
163
+ run: async (a) => client.get(`/apps/${a.appId}`),
164
+ },
165
+
166
+ // ---- App-level info (name / subtitle / privacy policy) ----
167
+ {
168
+ name: "list_app_infos",
169
+ description:
170
+ "List the appInfo records for an app. Each appInfo holds the localizations for the app NAME, SUBTITLE and privacy policy. There is typically one editable (state not READY_FOR_SALE) appInfo. Use its id with list_app_info_localizations.",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: { appId: { type: "string" } },
174
+ required: ["appId"],
175
+ },
176
+ run: async (a) => {
177
+ const data = await client.getAll(`/apps/${a.appId}/appInfos`);
178
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
179
+ },
180
+ },
181
+ {
182
+ name: "list_app_info_localizations",
183
+ description:
184
+ "List localizations of an appInfo. Each one holds the app NAME, SUBTITLE, privacyPolicyUrl and privacyPolicyText for a given locale.",
185
+ inputSchema: {
186
+ type: "object",
187
+ properties: { appInfoId: { type: "string" } },
188
+ required: ["appInfoId"],
189
+ },
190
+ run: async (a) => {
191
+ const data = await client.getAll(
192
+ `/appInfos/${a.appInfoId}/appInfoLocalizations`,
193
+ );
194
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
195
+ },
196
+ },
197
+ {
198
+ name: "update_app_info_localization",
199
+ description:
200
+ "Update the app NAME, SUBTITLE, privacy policy for one locale. Pass the appInfoLocalization id (from list_app_info_localizations). Only include the fields you want to change. Set dryRun:true to preview the diff (old→new + length checks) without writing anything.",
201
+ inputSchema: {
202
+ type: "object",
203
+ properties: {
204
+ localizationId: { type: "string" },
205
+ name: { type: "string", description: "App name (max 30 chars)" },
206
+ subtitle: { type: "string", description: "Subtitle (max 30 chars)" },
207
+ privacyPolicyUrl: { type: "string" },
208
+ privacyPolicyText: { type: "string" },
209
+ dryRun: {
210
+ type: "boolean",
211
+ description: "Preview changes without writing (default false)",
212
+ },
213
+ },
214
+ required: ["localizationId"],
215
+ },
216
+ run: async (a) => {
217
+ const attributes = {};
218
+ for (const k of [
219
+ "name",
220
+ "subtitle",
221
+ "privacyPolicyUrl",
222
+ "privacyPolicyText",
223
+ ])
224
+ if (a[k] !== undefined) attributes[k] = a[k];
225
+ return previewOrApply({
226
+ dryRun: a.dryRun,
227
+ id: a.localizationId,
228
+ attributes,
229
+ fetchCurrent: () =>
230
+ client.get(`/appInfoLocalizations/${a.localizationId}`),
231
+ apply: () =>
232
+ client.patch(`/appInfoLocalizations/${a.localizationId}`, {
233
+ data: {
234
+ type: "appInfoLocalizations",
235
+ id: a.localizationId,
236
+ attributes,
237
+ },
238
+ }),
239
+ });
240
+ },
241
+ },
242
+ {
243
+ name: "create_app_info_localization",
244
+ description:
245
+ "Add a new locale's name/subtitle/privacy policy to an appInfo (for a locale that doesn't exist yet).",
246
+ inputSchema: {
247
+ type: "object",
248
+ properties: {
249
+ appInfoId: { type: "string" },
250
+ locale: { type: "string", description: "e.g. 'fr-FR', 'de-DE'" },
251
+ name: { type: "string" },
252
+ subtitle: { type: "string" },
253
+ privacyPolicyUrl: { type: "string" },
254
+ privacyPolicyText: { type: "string" },
255
+ },
256
+ required: ["appInfoId", "locale"],
257
+ },
258
+ run: async (a) => {
259
+ const attributes = { locale: a.locale };
260
+ for (const k of ["name", "subtitle", "privacyPolicyUrl", "privacyPolicyText"])
261
+ if (a[k] !== undefined) attributes[k] = a[k];
262
+ return client.post(`/appInfoLocalizations`, {
263
+ data: {
264
+ type: "appInfoLocalizations",
265
+ attributes,
266
+ relationships: {
267
+ appInfo: { data: { type: "appInfos", id: a.appInfoId } },
268
+ },
269
+ },
270
+ });
271
+ },
272
+ },
273
+
274
+ // ---- Versions ----
275
+ {
276
+ name: "list_app_store_versions",
277
+ description:
278
+ "List App Store versions for an app (e.g. 1.2.0). Filter by state to find the editable one (PREPARE_FOR_SUBMISSION etc.).",
279
+ inputSchema: {
280
+ type: "object",
281
+ properties: {
282
+ appId: { type: "string" },
283
+ filterState: {
284
+ type: "string",
285
+ description:
286
+ "Optional appStoreState filter, e.g. PREPARE_FOR_SUBMISSION, READY_FOR_SALE",
287
+ },
288
+ filterPlatform: {
289
+ type: "string",
290
+ description: "IOS, MAC_OS, TV_OS, VISION_OS",
291
+ },
292
+ },
293
+ required: ["appId"],
294
+ },
295
+ run: async (a) => {
296
+ const query = {};
297
+ if (a.filterState) query["filter[appStoreState]"] = a.filterState;
298
+ if (a.filterPlatform) query["filter[platform]"] = a.filterPlatform;
299
+ const data = await client.getAll(
300
+ `/apps/${a.appId}/appStoreVersions`,
301
+ query,
302
+ );
303
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
304
+ },
305
+ },
306
+ {
307
+ name: "create_app_store_version",
308
+ description:
309
+ "Create a new App Store version for an app (a new version string to prepare for submission).",
310
+ inputSchema: {
311
+ type: "object",
312
+ properties: {
313
+ appId: { type: "string" },
314
+ versionString: { type: "string", description: "e.g. '1.3.0'" },
315
+ platform: {
316
+ type: "string",
317
+ description: "IOS (default), MAC_OS, TV_OS, VISION_OS",
318
+ },
319
+ },
320
+ required: ["appId", "versionString"],
321
+ },
322
+ run: async (a) =>
323
+ client.post(`/appStoreVersions`, {
324
+ data: {
325
+ type: "appStoreVersions",
326
+ attributes: {
327
+ platform: a.platform || "IOS",
328
+ versionString: a.versionString,
329
+ },
330
+ relationships: {
331
+ app: { data: { type: "apps", id: a.appId } },
332
+ },
333
+ },
334
+ }),
335
+ },
336
+
337
+ // ---- Version localizations (description, keywords, etc.) ----
338
+ {
339
+ name: "list_app_store_version_localizations",
340
+ description:
341
+ "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.",
342
+ inputSchema: {
343
+ type: "object",
344
+ properties: { versionId: { type: "string" } },
345
+ required: ["versionId"],
346
+ },
347
+ run: async (a) => {
348
+ const data = await client.getAll(
349
+ `/appStoreVersions/${a.versionId}/appStoreVersionLocalizations`,
350
+ );
351
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
352
+ },
353
+ },
354
+ {
355
+ name: "get_app_store_version_localization",
356
+ description:
357
+ "Get one App Store version localization (description, keywords, promotional text, what's new, URLs) by its id.",
358
+ inputSchema: {
359
+ type: "object",
360
+ properties: { localizationId: { type: "string" } },
361
+ required: ["localizationId"],
362
+ },
363
+ run: async (a) =>
364
+ client.get(`/appStoreVersionLocalizations/${a.localizationId}`),
365
+ },
366
+ {
367
+ name: "update_app_store_version_localization",
368
+ description:
369
+ "Update KEYWORDS, DESCRIPTION, promotional text, what's new, marketing/support URLs for one locale. Pass the appStoreVersionLocalization id. Keywords is a comma-separated string, max 100 chars total. Only include fields you want to change. Set dryRun:true to preview the diff (old→new + length checks) without writing anything.",
370
+ inputSchema: {
371
+ type: "object",
372
+ properties: {
373
+ localizationId: { type: "string" },
374
+ keywords: {
375
+ type: "string",
376
+ description: "Comma-separated, max 100 chars total. e.g. 'todo,tasks,planner'",
377
+ },
378
+ description: { type: "string", description: "Max 4000 chars" },
379
+ promotionalText: { type: "string", description: "Max 170 chars" },
380
+ whatsNew: {
381
+ type: "string",
382
+ description: "Release notes / what's new, max 4000 chars",
383
+ },
384
+ marketingUrl: { type: "string" },
385
+ supportUrl: { type: "string" },
386
+ dryRun: {
387
+ type: "boolean",
388
+ description: "Preview changes without writing (default false)",
389
+ },
390
+ },
391
+ required: ["localizationId"],
392
+ },
393
+ run: async (a) => {
394
+ const attributes = {};
395
+ for (const k of [
396
+ "keywords",
397
+ "description",
398
+ "promotionalText",
399
+ "whatsNew",
400
+ "marketingUrl",
401
+ "supportUrl",
402
+ ])
403
+ if (a[k] !== undefined) attributes[k] = a[k];
404
+ return previewOrApply({
405
+ dryRun: a.dryRun,
406
+ id: a.localizationId,
407
+ attributes,
408
+ fetchCurrent: () =>
409
+ client.get(`/appStoreVersionLocalizations/${a.localizationId}`),
410
+ apply: () =>
411
+ client.patch(
412
+ `/appStoreVersionLocalizations/${a.localizationId}`,
413
+ {
414
+ data: {
415
+ type: "appStoreVersionLocalizations",
416
+ id: a.localizationId,
417
+ attributes,
418
+ },
419
+ },
420
+ ),
421
+ });
422
+ },
423
+ },
424
+ {
425
+ name: "create_app_store_version_localization",
426
+ description:
427
+ "Add a new locale to an App Store version with its description/keywords/etc.",
428
+ inputSchema: {
429
+ type: "object",
430
+ properties: {
431
+ versionId: { type: "string" },
432
+ locale: { type: "string", description: "e.g. 'de-DE'" },
433
+ description: { type: "string" },
434
+ keywords: { type: "string" },
435
+ promotionalText: { type: "string" },
436
+ whatsNew: { type: "string" },
437
+ marketingUrl: { type: "string" },
438
+ supportUrl: { type: "string" },
439
+ },
440
+ required: ["versionId", "locale"],
441
+ },
442
+ run: async (a) => {
443
+ const attributes = { locale: a.locale };
444
+ for (const k of [
445
+ "description",
446
+ "keywords",
447
+ "promotionalText",
448
+ "whatsNew",
449
+ "marketingUrl",
450
+ "supportUrl",
451
+ ])
452
+ if (a[k] !== undefined) attributes[k] = a[k];
453
+ return client.post(`/appStoreVersionLocalizations`, {
454
+ data: {
455
+ type: "appStoreVersionLocalizations",
456
+ attributes,
457
+ relationships: {
458
+ appStoreVersion: {
459
+ data: { type: "appStoreVersions", id: a.versionId },
460
+ },
461
+ },
462
+ },
463
+ });
464
+ },
465
+ },
466
+
467
+ // ---- Screenshots ----
468
+ {
469
+ name: "list_screenshot_sets",
470
+ description:
471
+ "List screenshot sets for an App Store version localization. Each set is tied to one device size (screenshotDisplayType, e.g. APP_IPHONE_67). Use a set id to list or upload screenshots.",
472
+ inputSchema: {
473
+ type: "object",
474
+ properties: { localizationId: { type: "string" } },
475
+ required: ["localizationId"],
476
+ },
477
+ run: async (a) => {
478
+ const data = await client.getAll(
479
+ `/appStoreVersionLocalizations/${a.localizationId}/appScreenshotSets`,
480
+ );
481
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
482
+ },
483
+ },
484
+ {
485
+ name: "create_screenshot_set",
486
+ description:
487
+ "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.",
488
+ inputSchema: {
489
+ type: "object",
490
+ properties: {
491
+ localizationId: { type: "string" },
492
+ displayType: { type: "string" },
493
+ },
494
+ required: ["localizationId", "displayType"],
495
+ },
496
+ run: async (a) =>
497
+ client.post(`/appScreenshotSets`, {
498
+ data: {
499
+ type: "appScreenshotSets",
500
+ attributes: { screenshotDisplayType: a.displayType },
501
+ relationships: {
502
+ appStoreVersionLocalization: {
503
+ data: {
504
+ type: "appStoreVersionLocalizations",
505
+ id: a.localizationId,
506
+ },
507
+ },
508
+ },
509
+ },
510
+ }),
511
+ },
512
+ {
513
+ name: "list_screenshots",
514
+ description: "List the screenshots in a screenshot set.",
515
+ inputSchema: {
516
+ type: "object",
517
+ properties: { screenshotSetId: { type: "string" } },
518
+ required: ["screenshotSetId"],
519
+ },
520
+ run: async (a) => {
521
+ const data = await client.getAll(
522
+ `/appScreenshotSets/${a.screenshotSetId}/appScreenshots`,
523
+ );
524
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
525
+ },
526
+ },
527
+ {
528
+ name: "upload_screenshot",
529
+ description:
530
+ "Upload a screenshot image file into a screenshot set. Handles the full reserve→upload→commit flow. Provide an absolute path to a PNG/JPEG on disk. The image must match the set's device dimensions.",
531
+ inputSchema: {
532
+ type: "object",
533
+ properties: {
534
+ screenshotSetId: { type: "string" },
535
+ filePath: {
536
+ type: "string",
537
+ description: "Absolute path to the image file",
538
+ },
539
+ fileName: {
540
+ type: "string",
541
+ description: "Optional override for the stored file name",
542
+ },
543
+ },
544
+ required: ["screenshotSetId", "filePath"],
545
+ },
546
+ run: async (a) => {
547
+ const buf = readFileSync(a.filePath);
548
+ const fileName = a.fileName || basename(a.filePath);
549
+ // 1. Reserve
550
+ const reservation = await client.post(`/appScreenshots`, {
551
+ data: {
552
+ type: "appScreenshots",
553
+ attributes: { fileName, fileSize: buf.length },
554
+ relationships: {
555
+ appScreenshotSet: {
556
+ data: { type: "appScreenshotSets", id: a.screenshotSetId },
557
+ },
558
+ },
559
+ },
560
+ });
561
+ const id = reservation.data.id;
562
+ const ops = reservation.data.attributes.uploadOperations;
563
+ // 2. Upload bytes
564
+ await client.uploadAsset(ops, buf);
565
+ // 3. Commit with checksum
566
+ const committed = await client.patch(`/appScreenshots/${id}`, {
567
+ data: {
568
+ type: "appScreenshots",
569
+ id,
570
+ attributes: {
571
+ uploaded: true,
572
+ sourceFileChecksum: AppStoreConnectClient.md5(buf),
573
+ },
574
+ },
575
+ });
576
+ return committed;
577
+ },
578
+ },
579
+ {
580
+ name: "delete_screenshot",
581
+ description: "Delete a screenshot by its id.",
582
+ inputSchema: {
583
+ type: "object",
584
+ properties: { screenshotId: { type: "string" } },
585
+ required: ["screenshotId"],
586
+ },
587
+ run: async (a) => {
588
+ await client.delete(`/appScreenshots/${a.screenshotId}`);
589
+ return { deleted: a.screenshotId };
590
+ },
591
+ },
592
+
593
+ // ---- Fleet-wide ASO health check ----
594
+ {
595
+ name: "audit_apps",
596
+ description:
597
+ "Fleet health check across ALL your apps (or a subset). For each app it inspects the editable App Store version + app info and flags listing/ASO issues: missing subtitle, missing/empty keywords, under-used keyword field (ASO opportunity), missing description, missing promotional text, missing what's-new, no editable version, single-locale-only, and (optionally) missing screenshots. Returns per-app findings plus an account-wide summary. Read-only — writes nothing. Ideal for indie devs managing many apps.",
598
+ inputSchema: {
599
+ type: "object",
600
+ properties: {
601
+ appIds: {
602
+ type: "array",
603
+ items: { type: "string" },
604
+ description: "Limit the audit to these app ids (default: all apps)",
605
+ },
606
+ limit: {
607
+ type: "number",
608
+ description: "Audit at most this many apps (default: all)",
609
+ },
610
+ checkScreenshots: {
611
+ type: "boolean",
612
+ description:
613
+ "Also check the primary locale for missing screenshots (slower — extra API calls). Default false.",
614
+ },
615
+ keywordUseThreshold: {
616
+ type: "number",
617
+ description:
618
+ "Flag the keyword field as under-used below this many chars (default 70 of 100).",
619
+ },
620
+ },
621
+ },
622
+ run: async (a) => {
623
+ const threshold = a.keywordUseThreshold ?? 70;
624
+ // 1. Gather the app list.
625
+ let apps = await client.getAll("/apps", { limit: 200 });
626
+ if (a.appIds?.length)
627
+ apps = apps.filter((x) => a.appIds.includes(x.id));
628
+ if (a.limit) apps = apps.slice(0, a.limit);
629
+
630
+ // 2. Audit each app with limited concurrency.
631
+ const findings = await mapLimit(apps, 6, async (app) => {
632
+ const issues = [];
633
+ const add = (severity, code, message) =>
634
+ issues.push({ severity, code, message });
635
+ const primaryLocale = app.attributes.primaryLocale;
636
+ try {
637
+ // -- App info: name / subtitle --
638
+ const appInfos = await client.getAll(
639
+ `/apps/${app.id}/appInfos`,
640
+ );
641
+ if (appInfos.length) {
642
+ const infoLocs = await client.getAll(
643
+ `/appInfos/${appInfos[0].id}/appInfoLocalizations`,
644
+ );
645
+ const infoLoc =
646
+ infoLocs.find((l) => l.attributes.locale === primaryLocale) ||
647
+ infoLocs[0];
648
+ if (infoLoc && !infoLoc.attributes.subtitle)
649
+ add("opportunity", "missing_subtitle", "No subtitle set (free ASO keywords).");
650
+ }
651
+
652
+ // -- Versions: pick the editable one --
653
+ const versions = await client.getAll(
654
+ `/apps/${app.id}/appStoreVersions`,
655
+ { limit: 20 },
656
+ );
657
+ const editable = versions.find((v) =>
658
+ EDITABLE_VERSION_STATES.has(v.attributes.appStoreState),
659
+ );
660
+ if (!editable) {
661
+ add(
662
+ "info",
663
+ "no_editable_version",
664
+ "No version in an editable state — metadata can't be changed right now.",
665
+ );
666
+ } else {
667
+ const locs = await client.getAll(
668
+ `/appStoreVersions/${editable.id}/appStoreVersionLocalizations`,
669
+ );
670
+ if (locs.length <= 1)
671
+ add(
672
+ "opportunity",
673
+ "single_locale",
674
+ "Listing exists in only one locale — localizing can widen reach.",
675
+ );
676
+ const loc =
677
+ locs.find((l) => l.attributes.locale === primaryLocale) ||
678
+ locs[0];
679
+ if (loc) {
680
+ const at = loc.attributes;
681
+ const kw = (at.keywords || "").trim();
682
+ if (!kw)
683
+ add("warning", "missing_keywords", "Keyword field is empty.");
684
+ else if (kw.length < threshold)
685
+ add(
686
+ "opportunity",
687
+ "keywords_underused",
688
+ `Keyword field uses only ${kw.length}/100 chars — room for more terms.`,
689
+ );
690
+ if (!at.description)
691
+ add("warning", "missing_description", "No description set.");
692
+ if (!at.promotionalText)
693
+ add(
694
+ "info",
695
+ "missing_promotional_text",
696
+ "No promotional text (can be updated without a new version).",
697
+ );
698
+ if (!at.whatsNew)
699
+ add("info", "missing_whats_new", "No what's-new / release notes.");
700
+
701
+ if (a.checkScreenshots) {
702
+ const sets = await client.getAll(
703
+ `/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`,
704
+ );
705
+ let total = 0;
706
+ for (const s of sets) {
707
+ const shots = await client.getAll(
708
+ `/appScreenshotSets/${s.id}/appScreenshots`,
709
+ );
710
+ total += shots.length;
711
+ }
712
+ if (total === 0)
713
+ add(
714
+ "warning",
715
+ "missing_screenshots",
716
+ "No screenshots on the primary locale.",
717
+ );
718
+ }
719
+ }
720
+ }
721
+ } catch (e) {
722
+ add("error", "audit_failed", `Could not fully audit: ${e.message}`);
723
+ }
724
+ return {
725
+ appId: app.id,
726
+ name: app.attributes.name,
727
+ bundleId: app.attributes.bundleId,
728
+ primaryLocale,
729
+ issueCount: issues.length,
730
+ issues,
731
+ };
732
+ });
733
+
734
+ // 3. Account-wide summary.
735
+ const byCode = {};
736
+ let cleanApps = 0;
737
+ for (const f of findings) {
738
+ if (f.issueCount === 0) cleanApps++;
739
+ for (const i of f.issues) byCode[i.code] = (byCode[i.code] || 0) + 1;
740
+ }
741
+ return {
742
+ summary: {
743
+ appsAudited: findings.length,
744
+ appsWithNoIssues: cleanApps,
745
+ appsWithIssues: findings.length - cleanApps,
746
+ issuesByType: byCode,
747
+ screenshotsChecked: !!a.checkScreenshots,
748
+ },
749
+ findings: findings.sort((x, y) => y.issueCount - x.issueCount),
750
+ };
751
+ },
752
+ },
753
+
754
+ // ---- Generic escape hatch ----
755
+ {
756
+ name: "raw_request",
757
+ description:
758
+ "Make a raw App Store Connect API call — use this for ANY endpoint not covered by a dedicated tool (app previews, pricing, TestFlight, in-app purchases, reviews, analytics, sales reports, etc.). path is relative (e.g. '/apps' or '/appStoreVersions/{id}') and '/v1' is added automatically; you can also pass a full https URL. See developer.apple.com/documentation/appstoreconnectapi.",
759
+ inputSchema: {
760
+ type: "object",
761
+ properties: {
762
+ method: {
763
+ type: "string",
764
+ enum: ["GET", "POST", "PATCH", "DELETE"],
765
+ },
766
+ path: { type: "string" },
767
+ query: {
768
+ type: "object",
769
+ description: "Query params as a flat object",
770
+ additionalProperties: true,
771
+ },
772
+ body: {
773
+ type: "object",
774
+ description: "JSON request body (for POST/PATCH)",
775
+ additionalProperties: true,
776
+ },
777
+ },
778
+ required: ["method", "path"],
779
+ },
780
+ run: async (a) =>
781
+ client.request(a.method, a.path, { query: a.query, body: a.body }),
782
+ },
783
+ ];
784
+
785
+ const toolMap = Object.fromEntries(tools.map((t) => [t.name, t]));
786
+
787
+ // ---- Server wiring ----------------------------------------------------------
788
+
789
+ const server = new Server(
790
+ { name: "appstore-connect", version: "1.0.0" },
791
+ { capabilities: { tools: {} } },
792
+ );
793
+
794
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
795
+ tools: tools.map(({ name, description, inputSchema }) => ({
796
+ name,
797
+ description,
798
+ inputSchema,
799
+ })),
800
+ }));
801
+
802
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
803
+ const tool = toolMap[req.params.name];
804
+ if (!tool) return fail(new Error(`Unknown tool: ${req.params.name}`));
805
+ try {
806
+ const result = await tool.run(req.params.arguments || {});
807
+ return ok(result);
808
+ } catch (e) {
809
+ return fail(e);
810
+ }
811
+ });
812
+
813
+ const transport = new StdioServerTransport();
814
+ await server.connect(transport);
815
+ console.error("App Store Connect MCP server running on stdio");