appstore-api-mcp 1.1.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.3.0] - 2026-06-02
8
+
9
+ ### Added
10
+ - Promoted popular App Store Connect capabilities from `raw_request` to dedicated tools:
11
+ - **Customer reviews:** `list_customer_reviews` (filter by rating/territory, shows reply status) + `reply_to_customer_review`.
12
+ - **TestFlight:** `list_builds`, `list_beta_groups`, `list_beta_testers`, `add_beta_tester`.
13
+ - **Catalog/pricing/availability:** `list_in_app_purchases`, `get_app_price_schedule`, `list_available_territories`, `get_age_rating`.
14
+ - All read tools validated end-to-end against a live account (reviews, builds, groups, testers, IAPs, 175 territories, age rating).
15
+
16
+ ## [1.2.0] - 2026-06-02
17
+
18
+ ### Added
19
+ - **`get_screenshot`** — fetch a live screenshot as an actual **image the agent
20
+ can see** (not just metadata). Downloads the App Store Connect image asset,
21
+ downscaled by default (`maxWidth`), and returns an `image/png` content block.
22
+ Lets an agent review/compare what's currently on a listing.
23
+
7
24
  ## [1.1.3] - 2026-06-02
8
25
 
9
26
  ### Changed
package/README.md CHANGED
@@ -50,6 +50,7 @@ whatever AI agent you already use.
50
50
  - [Getting your API key](#getting-your-api-key) → full guide in [docs/SETUP.md](docs/SETUP.md)
51
51
  - [Configuration](#configuration)
52
52
  - [Tools](#tools) → full reference in [docs/TOOLS.md](docs/TOOLS.md)
53
+ - [What you can ask](#what-you-can-ask)
53
54
  - [Analytics & reports setup](docs/ANALYTICS.md) — roles, Vendor Number, examples
54
55
  - [Common workflows](#common-workflows)
55
56
  - [Security](#security) → details in [docs/SECURITY.md](docs/SECURITY.md)
@@ -242,16 +243,47 @@ Full parameter reference: **[docs/TOOLS.md](docs/TOOLS.md)**.
242
243
  | `create_app_store_version_localization` | Add a new locale to a version |
243
244
  | `list_screenshot_sets` / `create_screenshot_set` | Manage per-device screenshot sets |
244
245
  | `list_screenshots` / `upload_screenshot` / `delete_screenshot` | Manage screenshots (upload handles the full reserve→upload→commit flow) |
246
+ | 👁️ `get_screenshot` | Fetch a live screenshot **as an image the agent can see** — review/compare what's currently on a listing |
245
247
  | 🚀 `audit_apps` | **Fleet health check** — scan all (or selected) apps for missing subtitle/keywords/description, under-used keyword field, single-locale listings, missing screenshots, and more. Read-only. |
246
248
  | 📊 `get_sales_report` / `get_subscription_report` / `get_finance_report` | Units/downloads, proceeds, **subscriptions & retention**, earnings by region (parsed rows). Needs a Vendor Number + a key with Admin/Finance/Sales role. |
247
249
  | 📈 `request_analytics_report` → `list_analytics_reports` → `list_analytics_report_instances` → `get_analytics_report_data` | The async Analytics Reports API — downloads, sessions, active devices, App Store engagement. |
248
- | `raw_request` | Any method/path against the API — previews, pricing, TestFlight, IAP, reviews, … |
250
+ | ⭐ `list_customer_reviews` / `reply_to_customer_review` | Read reviews (filter by rating/territory, shows if you've replied) and post public replies. |
251
+ | ✈️ `list_builds` / `list_beta_groups` / `list_beta_testers` / `add_beta_tester` | TestFlight — builds, beta groups, testers, and inviting testers. |
252
+ | 🛒 `list_in_app_purchases` / `get_app_price_schedule` / `list_available_territories` / `get_age_rating` | Catalog, pricing, territory availability, and age-rating. |
253
+ | `raw_request` | Any method/path against the API — app previews, sales/finance edge cases, anything not above. |
249
254
 
250
255
  > 🛡️ **Dry-run:** the `update_*` tools accept `dryRun: true` — they return a field-by-field
251
256
  > diff (old → new) with length/limit checks and **write nothing**. Drop the flag to apply.
252
257
 
253
258
  ---
254
259
 
260
+ ## What you can ask
261
+
262
+ Once it's connected, just talk to your agent in plain language. A few things to try:
263
+
264
+ **Listings & ASO**
265
+ - *"Which of my apps are missing a subtitle or screenshots?"* (fleet audit)
266
+ - *"Tighten the keywords for <app> — dry-run it first so I can confirm."*
267
+ - *"Show me <app>'s current screenshots and flag any that look outdated."*
268
+ - *"Translate <app>'s description and keywords into German and French."*
269
+
270
+ **Performance**
271
+ - *"How many downloads did all my apps get last week?"*
272
+ - *"Pull last month's proceeds broken down by country."*
273
+ - *"How many active subscribers do I have, and what's the recent churn?"*
274
+
275
+ **Releases & the rest of the API** (via `raw_request`)
276
+ - *"Create a new App Store version (1.4.0) for <app>."*
277
+ - *"Summarize this week's 1-star reviews and draft polite replies."*
278
+ - *"List my latest TestFlight builds and which beta groups can see them."*
279
+ - *"What's <app>'s price and which territories is it available in?"*
280
+
281
+ > **Dedicated tools cover it all:** app management, localization, screenshots
282
+ > (incl. *seeing* them), sales/subscriptions/analytics, ASO audit, dry-run,
283
+ > **customer reviews, TestFlight, in-app purchases, pricing, availability, and
284
+ > age-rating**. Anything else in the App Store Connect API is still reachable
285
+ > through the **`raw_request`** escape hatch — just ask.
286
+
255
287
  ## Common workflows
256
288
 
257
289
  ### Update keywords or description
package/docs/TOOLS.md CHANGED
@@ -117,6 +117,13 @@ Common `displayType` values: `APP_IPHONE_67`, `APP_IPHONE_65`, `APP_IPHONE_61`,
117
117
  ### `list_screenshots`
118
118
  - `screenshotSetId` **(required)**
119
119
 
120
+ ### `get_screenshot`
121
+ Fetch the actual screenshot **image** (not just metadata) and return it as an
122
+ image the agent can see — review/compare what's currently live.
123
+ - `screenshotId` **(required)**
124
+ - `maxWidth` — downscale width in px for a lighter preview (default `750`; `0` = full size)
125
+ - **Returns:** an `image/png` content block + a caption with the file name and original dimensions.
126
+
120
127
  ### `upload_screenshot`
121
128
  Upload an image into a set. Handles reserve → upload bytes → commit (with checksum).
122
129
  - `screenshotSetId` **(required)**
@@ -259,6 +266,52 @@ Download + decompress + parse an instance's segments into rows.
259
266
 
260
267
  ---
261
268
 
269
+ ## Customer reviews
270
+
271
+ ### list_customer_reviews
272
+ - `appId` **(required)**
273
+ - `rating` — filter to a star rating 1–5
274
+ - `territory` — 3-letter code, e.g. `USA`, `GBR`
275
+ - `sort` — `-createdDate` (default), `createdDate`, `rating`, `-rating`
276
+ - `limit` — max reviews (default 50)
277
+ - **Returns:** reviews with `rating`, `title`, `body`, `reviewerNickname`, `createdDate`, `territory`, and `hasResponse`.
278
+
279
+ ### reply_to_customer_review
280
+ Posts a **public** reply (confirm the text with the user first).
281
+ - `reviewId` **(required)**
282
+ - `responseBody` **(required)** — max ~5970 chars
283
+
284
+ ## TestFlight
285
+
286
+ ### list_builds
287
+ - `appId` **(required)**, `limit` (default 25) — newest first: `version`, `processingState`, upload/expiration dates, min OS.
288
+
289
+ ### list_beta_groups
290
+ - `appId` **(required)** — group `name`, internal/external, public-link status.
291
+
292
+ ### list_beta_testers
293
+ - `appId` **or** `betaGroupId` **(one required)**, `limit` (default 100).
294
+
295
+ ### add_beta_tester
296
+ Invites a tester by email (emails a real person — confirm first).
297
+ - `betaGroupId` **(required)**, `email` **(required)**, `firstName`, `lastName`
298
+
299
+ ## Catalog, pricing & availability
300
+
301
+ ### list_in_app_purchases
302
+ - `appId` **(required)** — `name`, `productId`, type, `state`.
303
+
304
+ ### get_app_price_schedule
305
+ - `appId` **(required)** — base territory + manual price points (raw schedule for inspection).
306
+
307
+ ### list_available_territories
308
+ - `appId` **(required)**, `limit` (default 200) — `{ count, territories: [{ territory, available, releaseDate }] }`.
309
+
310
+ ### get_age_rating
311
+ - `appId` **(required)** — the app's age-rating declaration (content descriptors).
312
+
313
+ ---
314
+
262
315
  ## Raw API access
263
316
 
264
317
  ### `raw_request`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appstore-api-mcp",
3
- "version": "1.1.3",
3
+ "version": "1.3.0",
4
4
  "description": "MCP server for Apple App Store Connect — edit listings (keywords, descriptions, titles, screenshots), track analytics (downloads, proceeds, subscriptions, retention), run a fleet-wide ASO audit, preview changes with dry-run, and reach the full API. Works with any MCP client (Claude, Codex, Cursor, Windsurf, VS Code, Zed, Gemini CLI, Antigravity, Amazon Q, Goose, and more).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/client.js CHANGED
@@ -193,6 +193,32 @@ export class AppStoreConnectClient {
193
193
  : buf.toString("utf8");
194
194
  }
195
195
 
196
+ /** Fetch raw bytes from a URL (e.g. an image asset). Returns a Buffer. */
197
+ async fetchBinary(url) {
198
+ const res = await fetch(url);
199
+ if (!res.ok)
200
+ throw new Error(`Image fetch failed (${res.status}) for ${url}`);
201
+ return Buffer.from(await res.arrayBuffer());
202
+ }
203
+
204
+ /**
205
+ * Build a concrete image URL from an App Store Connect imageAsset
206
+ * (`{ templateUrl, width, height }`), optionally downscaled to maxWidth.
207
+ */
208
+ static imageUrlFromAsset(imageAsset, maxWidth, format = "png") {
209
+ if (!imageAsset || !imageAsset.templateUrl) return null;
210
+ let w = imageAsset.width || 0;
211
+ let h = imageAsset.height || 0;
212
+ if (maxWidth && w && h && w > maxWidth) {
213
+ h = Math.round((h * maxWidth) / w);
214
+ w = maxWidth;
215
+ }
216
+ return imageAsset.templateUrl
217
+ .replace("{w}", String(w))
218
+ .replace("{h}", String(h))
219
+ .replace("{f}", format);
220
+ }
221
+
196
222
  /** Parse TSV/CSV text into an array of row objects. Auto-detects delimiter. */
197
223
  static parseDelimited(text, delimiter) {
198
224
  const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
package/src/index.js CHANGED
@@ -619,6 +619,49 @@ const tools = [
619
619
  return committed;
620
620
  },
621
621
  },
622
+ {
623
+ name: "get_screenshot",
624
+ description:
625
+ "Fetch the actual screenshot IMAGE by its id and return it so the agent can SEE it (not just metadata). Downloads the live image asset from App Store Connect, downscaled for a quick preview by default. Use this to review/compare what's currently live on a listing.",
626
+ inputSchema: {
627
+ type: "object",
628
+ properties: {
629
+ screenshotId: { type: "string" },
630
+ maxWidth: {
631
+ type: "number",
632
+ description: "Downscale to this width in px for a lighter preview (default 750; pass 0 for full size)",
633
+ },
634
+ },
635
+ required: ["screenshotId"],
636
+ },
637
+ run: async (a) => {
638
+ const res = await client.get(`/appScreenshots/${a.screenshotId}`);
639
+ const attr = res.data.attributes || {};
640
+ const asset = attr.imageAsset;
641
+ if (!asset || !asset.templateUrl)
642
+ return {
643
+ note: "This screenshot has no rendered image yet (still uploading/processing). State: " +
644
+ (attr.assetDeliveryState && attr.assetDeliveryState.state),
645
+ fileName: attr.fileName,
646
+ };
647
+ const maxWidth = a.maxWidth === undefined ? 750 : a.maxWidth || 0;
648
+ const url = AppStoreConnectClient.imageUrlFromAsset(asset, maxWidth, "png");
649
+ const buf = await client.fetchBinary(url);
650
+ return {
651
+ __mcpContent: [
652
+ {
653
+ type: "image",
654
+ data: buf.toString("base64"),
655
+ mimeType: "image/png",
656
+ },
657
+ {
658
+ type: "text",
659
+ text: `Screenshot ${a.screenshotId} — ${attr.fileName || "(no name)"}, original ${asset.width}×${asset.height}.`,
660
+ },
661
+ ],
662
+ };
663
+ },
664
+ },
622
665
  {
623
666
  name: "delete_screenshot",
624
667
  description: "Delete a screenshot by its id.",
@@ -1038,6 +1081,262 @@ const tools = [
1038
1081
  },
1039
1082
  },
1040
1083
 
1084
+ // ---- Customer reviews ----
1085
+ {
1086
+ name: "list_customer_reviews",
1087
+ description:
1088
+ "List customer reviews for an app. Filter by rating (1–5) and/or territory (3-letter code, e.g. USA, GBR). Sorted newest-first by default. Each review includes whether you've already responded.",
1089
+ inputSchema: {
1090
+ type: "object",
1091
+ properties: {
1092
+ appId: { type: "string" },
1093
+ rating: { type: "number", description: "Filter to a star rating 1–5" },
1094
+ territory: { type: "string", description: "3-letter territory code, e.g. USA" },
1095
+ sort: {
1096
+ type: "string",
1097
+ description: "'-createdDate' (default, newest first), 'createdDate', 'rating', '-rating'",
1098
+ },
1099
+ limit: { type: "number", description: "Max reviews (default 50)" },
1100
+ },
1101
+ required: ["appId"],
1102
+ },
1103
+ run: async (a) => {
1104
+ const query = {
1105
+ sort: a.sort || "-createdDate",
1106
+ limit: a.limit ?? 50,
1107
+ include: "response",
1108
+ };
1109
+ if (a.rating !== undefined) query["filter[rating]"] = a.rating;
1110
+ if (a.territory) query["filter[territory]"] = a.territory;
1111
+ const data = await client.getAll(
1112
+ `/apps/${a.appId}/customerReviews`,
1113
+ query,
1114
+ );
1115
+ return data.map((x) => ({
1116
+ id: x.id,
1117
+ ...x.attributes,
1118
+ hasResponse: !!(x.relationships?.response?.data),
1119
+ }));
1120
+ },
1121
+ },
1122
+ {
1123
+ name: "reply_to_customer_review",
1124
+ description:
1125
+ "Publicly reply to a customer review. NOTE: this publishes a response visible on the App Store — confirm the text with the user first. responseBody max ~5970 chars.",
1126
+ inputSchema: {
1127
+ type: "object",
1128
+ properties: {
1129
+ reviewId: { type: "string" },
1130
+ responseBody: { type: "string" },
1131
+ },
1132
+ required: ["reviewId", "responseBody"],
1133
+ },
1134
+ run: async (a) =>
1135
+ client.post("/customerReviewResponses", {
1136
+ data: {
1137
+ type: "customerReviewResponses",
1138
+ attributes: { responseBody: a.responseBody },
1139
+ relationships: {
1140
+ review: { data: { type: "customerReviews", id: a.reviewId } },
1141
+ },
1142
+ },
1143
+ }),
1144
+ },
1145
+
1146
+ // ---- TestFlight ----
1147
+ {
1148
+ name: "list_builds",
1149
+ description:
1150
+ "List TestFlight builds for an app (newest first): version, upload/expiration dates, processing state, min OS.",
1151
+ inputSchema: {
1152
+ type: "object",
1153
+ properties: {
1154
+ appId: { type: "string" },
1155
+ limit: { type: "number", description: "Max builds (default 25)" },
1156
+ },
1157
+ required: ["appId"],
1158
+ },
1159
+ run: async (a) => {
1160
+ // The /builds collection supports sort; the app relationship does not.
1161
+ const data = await client.getAll(`/builds`, {
1162
+ "filter[app]": a.appId,
1163
+ sort: "-version",
1164
+ limit: a.limit ?? 25,
1165
+ });
1166
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
1167
+ },
1168
+ },
1169
+ {
1170
+ name: "list_beta_groups",
1171
+ description:
1172
+ "List TestFlight beta groups for an app (internal/external, public-link status).",
1173
+ inputSchema: {
1174
+ type: "object",
1175
+ properties: { appId: { type: "string" } },
1176
+ required: ["appId"],
1177
+ },
1178
+ run: async (a) => {
1179
+ const data = await client.getAll(`/apps/${a.appId}/betaGroups`);
1180
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
1181
+ },
1182
+ },
1183
+ {
1184
+ name: "list_beta_testers",
1185
+ description:
1186
+ "List TestFlight beta testers — either for a whole app (pass appId) or a specific group (pass betaGroupId).",
1187
+ inputSchema: {
1188
+ type: "object",
1189
+ properties: {
1190
+ appId: { type: "string" },
1191
+ betaGroupId: { type: "string" },
1192
+ limit: { type: "number", description: "Max testers (default 100)" },
1193
+ },
1194
+ },
1195
+ run: async (a) => {
1196
+ let data;
1197
+ if (a.betaGroupId)
1198
+ data = await client.getAll(
1199
+ `/betaGroups/${a.betaGroupId}/betaTesters`,
1200
+ { limit: a.limit ?? 100 },
1201
+ );
1202
+ else if (a.appId)
1203
+ data = await client.getAll(`/betaTesters`, {
1204
+ "filter[apps]": a.appId,
1205
+ limit: a.limit ?? 100,
1206
+ });
1207
+ else throw new Error("Provide appId or betaGroupId.");
1208
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
1209
+ },
1210
+ },
1211
+ {
1212
+ name: "add_beta_tester",
1213
+ description:
1214
+ "Add a beta tester to a TestFlight group by email (sends them an invite). NOTE: this emails a real person — confirm with the user first.",
1215
+ inputSchema: {
1216
+ type: "object",
1217
+ properties: {
1218
+ betaGroupId: { type: "string" },
1219
+ email: { type: "string" },
1220
+ firstName: { type: "string" },
1221
+ lastName: { type: "string" },
1222
+ },
1223
+ required: ["betaGroupId", "email"],
1224
+ },
1225
+ run: async (a) => {
1226
+ const attributes = { email: a.email };
1227
+ if (a.firstName) attributes.firstName = a.firstName;
1228
+ if (a.lastName) attributes.lastName = a.lastName;
1229
+ return client.post("/betaTesters", {
1230
+ data: {
1231
+ type: "betaTesters",
1232
+ attributes,
1233
+ relationships: {
1234
+ betaGroups: { data: [{ type: "betaGroups", id: a.betaGroupId }] },
1235
+ },
1236
+ },
1237
+ });
1238
+ },
1239
+ },
1240
+
1241
+ // ---- Catalog, pricing & availability ----
1242
+ {
1243
+ name: "list_in_app_purchases",
1244
+ description:
1245
+ "List the in-app purchase products for an app (name, product id, type, state).",
1246
+ inputSchema: {
1247
+ type: "object",
1248
+ properties: { appId: { type: "string" } },
1249
+ required: ["appId"],
1250
+ },
1251
+ run: async (a) => {
1252
+ const data = await client.getAll(`/apps/${a.appId}/inAppPurchasesV2`);
1253
+ return data.map((x) => ({ id: x.id, ...x.attributes }));
1254
+ },
1255
+ },
1256
+ {
1257
+ name: "list_available_territories",
1258
+ description:
1259
+ "List the territories (countries/regions) where an app is available. Returns territory codes and currencies.",
1260
+ inputSchema: {
1261
+ type: "object",
1262
+ properties: {
1263
+ appId: { type: "string" },
1264
+ limit: { type: "number", description: "Max territories (default 200)" },
1265
+ },
1266
+ required: ["appId"],
1267
+ },
1268
+ run: async (a) => {
1269
+ // v2 availability model: app → appAvailabilityV2 → territoryAvailabilities.
1270
+ // Follow the relationship's own related link (it points at the /v2 API).
1271
+ const av = await client.get(`/apps/${a.appId}/appAvailabilityV2`);
1272
+ const related =
1273
+ av.data?.relationships?.territoryAvailabilities?.links?.related;
1274
+ if (!related) return { note: "No availability record for this app." };
1275
+ // Apple caps page size at 200; getAll paginates to cover the rest.
1276
+ const data = await client.getAll(related, {
1277
+ limit: Math.min(a.limit ?? 200, 200),
1278
+ });
1279
+ const territories = data
1280
+ .map((x) => {
1281
+ // The territory code is base64-encoded JSON in the item id: {"s":appId,"t":"USA"}.
1282
+ let territory = null;
1283
+ try {
1284
+ territory = JSON.parse(
1285
+ Buffer.from(x.id, "base64").toString("utf8"),
1286
+ ).t;
1287
+ } catch {
1288
+ /* ignore */
1289
+ }
1290
+ return {
1291
+ territory,
1292
+ available: x.attributes?.available,
1293
+ releaseDate: x.attributes?.releaseDate,
1294
+ };
1295
+ })
1296
+ .filter((x) => x.territory);
1297
+ return {
1298
+ availableInNewTerritories: av.data?.attributes?.availableInNewTerritories,
1299
+ count: territories.length,
1300
+ territories,
1301
+ };
1302
+ },
1303
+ },
1304
+ {
1305
+ name: "get_age_rating",
1306
+ description:
1307
+ "Get an app's age-rating declaration (the content descriptors that determine its age rating).",
1308
+ inputSchema: {
1309
+ type: "object",
1310
+ properties: { appId: { type: "string" } },
1311
+ required: ["appId"],
1312
+ },
1313
+ run: async (a) => {
1314
+ const res = await client.get(`/apps/${a.appId}/appInfos`, {
1315
+ include: "ageRatingDeclaration",
1316
+ limit: 1,
1317
+ });
1318
+ const decl = (res.included || []).find(
1319
+ (x) => x.type === "ageRatingDeclarations",
1320
+ );
1321
+ return decl ? { id: decl.id, ...decl.attributes } : { note: "No age-rating declaration found." };
1322
+ },
1323
+ },
1324
+ {
1325
+ name: "get_app_price_schedule",
1326
+ description:
1327
+ "Get an app's price schedule (base territory + manual price points). Pricing in the App Store Connect API is multi-step; this returns the schedule with its included prices for inspection.",
1328
+ inputSchema: {
1329
+ type: "object",
1330
+ properties: { appId: { type: "string" } },
1331
+ required: ["appId"],
1332
+ },
1333
+ run: async (a) =>
1334
+ client.get(`/apps/${a.appId}/appPriceSchedule`, {
1335
+ include: "baseTerritory,manualPrices",
1336
+ "limit[manualPrices]": 50,
1337
+ }),
1338
+ },
1339
+
1041
1340
  // ---- Generic escape hatch ----
1042
1341
  {
1043
1342
  name: "raw_request",
@@ -1091,6 +1390,8 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1091
1390
  if (!tool) return fail(new Error(`Unknown tool: ${req.params.name}`));
1092
1391
  try {
1093
1392
  const result = await tool.run(req.params.arguments || {});
1393
+ // Tools may return raw MCP content (e.g. images) via __mcpContent.
1394
+ if (result && result.__mcpContent) return { content: result.__mcpContent };
1094
1395
  return ok(result);
1095
1396
  } catch (e) {
1096
1397
  if (e && e.status === 403 && REPORT_TOOLS.has(req.params.name))