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/docs/TOOLS.md ADDED
@@ -0,0 +1,239 @@
1
+ # Tool reference
2
+
3
+ Every tool the server exposes, with parameters and return shape. You normally
4
+ don't call these by hand — you ask the model in plain language and it picks the
5
+ right ones. This is for when you want the exact contract.
6
+
7
+ All tools return JSON (pretty-printed text). IDs are App Store Connect resource
8
+ ids (strings). Required params are marked **(required)**.
9
+
10
+ ---
11
+
12
+ ## Apps
13
+
14
+ ### `list_apps`
15
+ List all apps in the account.
16
+ - `limit` — max apps (default 100)
17
+ - `filterBundleId` — exact bundle id filter
18
+ - **Returns:** array of `{ id, name, bundleId, sku, primaryLocale, ... }`
19
+
20
+ ### `get_app`
21
+ Get one app's details.
22
+ - `appId` **(required)**
23
+
24
+ ---
25
+
26
+ ## App info — name, subtitle, privacy policy
27
+
28
+ These live on `appInfo` records, separate from version-specific copy.
29
+
30
+ ### `list_app_infos`
31
+ List the appInfo records for an app. Usually one is editable.
32
+ - `appId` **(required)**
33
+
34
+ ### `list_app_info_localizations`
35
+ List per-locale name/subtitle/privacy for an appInfo.
36
+ - `appInfoId` **(required)**
37
+ - **Returns:** array of `{ id, locale, name, subtitle, privacyPolicyUrl, privacyPolicyText }`
38
+
39
+ ### `update_app_info_localization`
40
+ Update name/subtitle/privacy for one locale. Only pass fields you want to change.
41
+ - `localizationId` **(required)** — an appInfoLocalization id
42
+ - `name` — app name (max 30 chars)
43
+ - `subtitle` — subtitle (max 30 chars)
44
+ - `privacyPolicyUrl`
45
+ - `privacyPolicyText`
46
+ - `dryRun` — if true, **write nothing**; return a diff + length/limit checks (see [Dry-run mode](#dry-run-mode))
47
+
48
+ ### `create_app_info_localization`
49
+ Add a brand-new locale's name/subtitle/privacy.
50
+ - `appInfoId` **(required)**
51
+ - `locale` **(required)** — e.g. `fr-FR`, `de-DE`
52
+ - `name`, `subtitle`, `privacyPolicyUrl`, `privacyPolicyText`
53
+
54
+ ---
55
+
56
+ ## Versions
57
+
58
+ ### `list_app_store_versions`
59
+ List versions and states for an app.
60
+ - `appId` **(required)**
61
+ - `filterState` — e.g. `PREPARE_FOR_SUBMISSION`, `READY_FOR_SALE`
62
+ - `filterPlatform` — `IOS`, `MAC_OS`, `TV_OS`, `VISION_OS`
63
+
64
+ ### `create_app_store_version`
65
+ Create a new version to prepare for submission.
66
+ - `appId` **(required)**
67
+ - `versionString` **(required)** — e.g. `1.3.0`
68
+ - `platform` — default `IOS`
69
+
70
+ ---
71
+
72
+ ## Version localizations — description, keywords, what's-new
73
+
74
+ ### `list_app_store_version_localizations`
75
+ List per-locale listing copy for a version.
76
+ - `versionId` **(required)**
77
+ - **Returns:** array of `{ id, locale, description, keywords, promotionalText, whatsNew, marketingUrl, supportUrl }`
78
+
79
+ ### `get_app_store_version_localization`
80
+ Read one locale's listing copy.
81
+ - `localizationId` **(required)**
82
+
83
+ ### `update_app_store_version_localization`
84
+ Update keywords/description/etc. for one locale. Only pass fields to change.
85
+ - `localizationId` **(required)**
86
+ - `keywords` — comma-separated, **max 100 chars total** (e.g. `todo,tasks,planner`)
87
+ - `description` — max 4000 chars
88
+ - `promotionalText` — max 170 chars (editable without a new version)
89
+ - `whatsNew` — release notes, max 4000 chars
90
+ - `marketingUrl`
91
+ - `supportUrl`
92
+ - `dryRun` — if true, **write nothing**; return a diff + length/limit checks (see [Dry-run mode](#dry-run-mode))
93
+
94
+ ### `create_app_store_version_localization`
95
+ Add a new locale to a version.
96
+ - `versionId` **(required)**
97
+ - `locale` **(required)**
98
+ - `description`, `keywords`, `promotionalText`, `whatsNew`, `marketingUrl`, `supportUrl`
99
+
100
+ ---
101
+
102
+ ## Screenshots
103
+
104
+ Screenshots are organized into **sets**, one per device display type, attached to
105
+ a version localization.
106
+
107
+ Common `displayType` values: `APP_IPHONE_67`, `APP_IPHONE_65`, `APP_IPHONE_61`,
108
+ `APP_IPHONE_55`, `APP_IPAD_PRO_129`, `APP_IPAD_PRO_3GEN_11`.
109
+
110
+ ### `list_screenshot_sets`
111
+ - `localizationId` **(required)** — an appStoreVersionLocalization id
112
+
113
+ ### `create_screenshot_set`
114
+ - `localizationId` **(required)**
115
+ - `displayType` **(required)**
116
+
117
+ ### `list_screenshots`
118
+ - `screenshotSetId` **(required)**
119
+
120
+ ### `upload_screenshot`
121
+ Upload an image into a set. Handles reserve → upload bytes → commit (with checksum).
122
+ - `screenshotSetId` **(required)**
123
+ - `filePath` **(required)** — absolute path to a PNG/JPEG matching the device's exact dimensions
124
+ - `fileName` — optional override for the stored name
125
+
126
+ ### `delete_screenshot`
127
+ - `screenshotId` **(required)**
128
+
129
+ ---
130
+
131
+ ## Fleet audit
132
+
133
+ ### audit_apps
134
+ Read-only health check across **all** your apps (or a subset). For each app it
135
+ inspects the editable App Store version and app info, then flags listing/ASO
136
+ issues. Writes nothing.
137
+
138
+ - `appIds` — array of app ids to limit the audit to (default: all apps)
139
+ - `limit` — audit at most this many apps (default: all)
140
+ - `checkScreenshots` — also flag the primary locale when it has no screenshots
141
+ (slower; extra API calls). Default `false`.
142
+ - `keywordUseThreshold` — flag the keyword field as under-used below this many
143
+ chars (default `70` of 100).
144
+
145
+ **Issue codes** (each finding has `severity` of `error` / `warning` / `info` / `opportunity`):
146
+
147
+ | code | meaning |
148
+ | --- | --- |
149
+ | `missing_subtitle` | No subtitle (wasted free ASO keywords) |
150
+ | `missing_keywords` | Keyword field empty |
151
+ | `keywords_underused` | Keyword field uses < threshold of 100 chars |
152
+ | `missing_description` | No description |
153
+ | `missing_promotional_text` | No promotional text |
154
+ | `missing_whats_new` | No release notes |
155
+ | `single_locale` | Listing in only one locale |
156
+ | `missing_screenshots` | No screenshots (only when `checkScreenshots: true`) |
157
+ | `no_editable_version` | No version in an editable state right now |
158
+ | `audit_failed` | The app could not be fully read (details in the message) |
159
+
160
+ **Returns**
161
+ ```jsonc
162
+ {
163
+ "summary": {
164
+ "appsAudited": 62,
165
+ "appsWithNoIssues": 11,
166
+ "appsWithIssues": 51,
167
+ "issuesByType": { "missing_subtitle": 8, "keywords_underused": 14, ... },
168
+ "screenshotsChecked": false
169
+ },
170
+ "findings": [
171
+ { "appId": "…", "name": "…", "bundleId": "…", "primaryLocale": "en-US",
172
+ "issueCount": 6, "issues": [ { "severity": "warning", "code": "missing_keywords", "message": "…" } ] }
173
+ ] // sorted by issueCount, worst first
174
+ }
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Dry-run mode
180
+
181
+ The `update_app_store_version_localization` and `update_app_info_localization`
182
+ tools accept `dryRun: true`. Instead of writing, the tool reads the current
183
+ values, computes a diff, validates lengths against Apple's limits, and returns:
184
+
185
+ ```jsonc
186
+ {
187
+ "dryRun": true,
188
+ "id": "…",
189
+ "changes": [
190
+ { "field": "keywords", "from": "old,kw", "to": "new,kw",
191
+ "changed": true, "newLength": 6, "limit": 100, "exceedsLimit": false }
192
+ ],
193
+ "warnings": [ "'keywords' is 111 chars — exceeds Apple's limit of 100." ],
194
+ "note": "No changes were written. Re-run without dryRun to apply."
195
+ }
196
+ ```
197
+
198
+ Re-run the same call **without** `dryRun` to apply. On a real write, any
199
+ length warnings are attached to the response under `_warnings`.
200
+
201
+ ---
202
+
203
+ ## Raw API access
204
+
205
+ ### `raw_request`
206
+ Call any App Store Connect endpoint not covered above — previews, pricing,
207
+ availability, TestFlight, in-app purchases, subscriptions, customer reviews,
208
+ analytics, sales & finance reports, etc.
209
+
210
+ - `method` **(required)** — `GET` | `POST` | `PATCH` | `DELETE`
211
+ - `path` **(required)** — relative (e.g. `/apps` or `/appStoreVersions/{id}`; `/v1`
212
+ is prepended automatically) or a full `https://…` URL (e.g. a paging `next` link)
213
+ - `query` — flat object of query params
214
+ - `body` — JSON request body for POST/PATCH
215
+
216
+ **Examples**
217
+ ```jsonc
218
+ // Read customer reviews
219
+ { "method": "GET", "path": "/apps/123456/customerReviews", "query": { "limit": 50 } }
220
+
221
+ // Reply to a review
222
+ { "method": "POST", "path": "/customerReviewResponses",
223
+ "body": { "data": { "type": "customerReviewResponses",
224
+ "attributes": { "responseBody": "Thanks for the feedback!" },
225
+ "relationships": { "review": { "data": { "type": "customerReviews", "id": "REVIEW_ID" } } } } } }
226
+ ```
227
+
228
+ Reference: <https://developer.apple.com/documentation/appstoreconnectapi>
229
+
230
+ ---
231
+
232
+ ## Notes on editability
233
+
234
+ - Listing copy (description, keywords, name, subtitle, what's-new) only **saves on a
235
+ version in an editable state** such as `PREPARE_FOR_SUBMISSION`. Promotional text
236
+ is the exception — it can be updated on a live version.
237
+ - Edits change the **draft**. Changes go public only after you submit and Apple approves.
238
+ - The API paginates; list tools auto-follow pages (up to a safety cap). For very
239
+ large accounts use `raw_request` with explicit `limit`/cursor if needed.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "appstore-api-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Apple App Store Connect — manage apps, keywords, descriptions, titles, screenshots, versions and the full API from any MCP client (Claude, Cursor, Cline, Windsurf, VS Code, Zed, Continue, and custom agents). Includes a fleet-wide ASO audit and dry-run previews.",
5
+ "type": "module",
6
+ "bin": {
7
+ "appstore-api-mcp": "src/index.js"
8
+ },
9
+ "main": "src/index.js",
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE",
14
+ "CHANGELOG.md",
15
+ "docs",
16
+ ".env.example"
17
+ ],
18
+ "scripts": {
19
+ "start": "node src/index.js"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "app-store-connect",
25
+ "appstore",
26
+ "app-store",
27
+ "itunes-connect",
28
+ "aso",
29
+ "ios",
30
+ "apple",
31
+ "claude",
32
+ "keywords",
33
+ "screenshots",
34
+ "metadata"
35
+ ],
36
+ "license": "MIT",
37
+ "author": "Sviatoslav Fil",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/fil-technology/appstore-api-mcp.git"
41
+ },
42
+ "homepage": "https://github.com/fil-technology/appstore-api-mcp#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/fil-technology/appstore-api-mcp/issues"
45
+ },
46
+ "dependencies": {
47
+ "@modelcontextprotocol/sdk": "^1.12.0",
48
+ "jose": "^5.9.6"
49
+ },
50
+ "engines": {
51
+ "node": ">=18"
52
+ }
53
+ }
package/src/client.js ADDED
@@ -0,0 +1,162 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { importPKCS8, SignJWT } from "jose";
4
+
5
+ const BASE_URL = "https://api.appstoreconnect.apple.com";
6
+
7
+ /**
8
+ * Thin client for the App Store Connect API.
9
+ * Handles ES256 JWT minting (cached until shortly before expiry) and
10
+ * exposes request helpers plus a multi-step screenshot uploader.
11
+ */
12
+ export class AppStoreConnectClient {
13
+ constructor({ keyId, issuerId, privateKeyPath, privateKey, privateKeyBase64 }) {
14
+ if (!keyId) throw new Error("ASC_KEY_ID is required");
15
+ if (!issuerId) throw new Error("ASC_ISSUER_ID is required");
16
+ this.keyId = keyId;
17
+ this.issuerId = issuerId;
18
+ // Accept the .p8 key three ways, in priority order:
19
+ // 1. inline PEM text (ASC_PRIVATE_KEY)
20
+ // 2. base64-encoded PEM (ASC_PRIVATE_KEY_BASE64) — easiest for env vars
21
+ // 3. path to the .p8 file (ASC_PRIVATE_KEY_PATH)
22
+ this._pem =
23
+ privateKey ||
24
+ (privateKeyBase64
25
+ ? Buffer.from(privateKeyBase64, "base64").toString("utf8")
26
+ : null) ||
27
+ (privateKeyPath ? readFileSync(privateKeyPath, "utf8") : null);
28
+ if (!this._pem)
29
+ throw new Error(
30
+ "Provide the API key via ASC_PRIVATE_KEY_PATH, ASC_PRIVATE_KEY, or ASC_PRIVATE_KEY_BASE64",
31
+ );
32
+ this._token = null;
33
+ this._tokenExp = 0;
34
+ }
35
+
36
+ async _getToken() {
37
+ const now = Math.floor(Date.now() / 1000);
38
+ // Reuse token while it still has >60s of life.
39
+ if (this._token && now < this._tokenExp - 60) return this._token;
40
+ const key = await importPKCS8(this._pem, "ES256");
41
+ const exp = now + 19 * 60; // App Store Connect caps token life at 20 min.
42
+ this._token = await new SignJWT({})
43
+ .setProtectedHeader({ alg: "ES256", kid: this.keyId, typ: "JWT" })
44
+ .setIssuedAt(now)
45
+ .setIssuer(this.issuerId)
46
+ .setExpirationTime(exp)
47
+ .setAudience("appstoreconnect-v1")
48
+ .sign(key);
49
+ this._tokenExp = exp;
50
+ return this._token;
51
+ }
52
+
53
+ /**
54
+ * Core request. `path` may be a full URL (e.g. a paging `next` link) or a
55
+ * path relative to the API root, with or without the leading /v1.
56
+ */
57
+ async request(method, path, { query, body } = {}) {
58
+ const token = await this._getToken();
59
+ let url;
60
+ if (/^https?:\/\//.test(path)) {
61
+ url = new URL(path);
62
+ } else {
63
+ const clean = path.startsWith("/") ? path : `/${path}`;
64
+ const withVersion = clean.startsWith("/v1") || clean.startsWith("/v2")
65
+ ? clean
66
+ : `/v1${clean}`;
67
+ url = new URL(BASE_URL + withVersion);
68
+ }
69
+ if (query) {
70
+ for (const [k, v] of Object.entries(query)) {
71
+ if (v === undefined || v === null) continue;
72
+ url.searchParams.set(k, Array.isArray(v) ? v.join(",") : String(v));
73
+ }
74
+ }
75
+ const headers = { Authorization: `Bearer ${token}` };
76
+ let payload;
77
+ if (body !== undefined) {
78
+ headers["Content-Type"] = "application/json";
79
+ payload = JSON.stringify(body);
80
+ }
81
+ const res = await fetch(url, { method, headers, body: payload });
82
+ const text = await res.text();
83
+ let data = null;
84
+ if (text) {
85
+ try {
86
+ data = JSON.parse(text);
87
+ } catch {
88
+ data = text;
89
+ }
90
+ }
91
+ if (!res.ok) {
92
+ const detail =
93
+ data && data.errors
94
+ ? data.errors
95
+ .map((e) => `${e.status} ${e.code}: ${e.title} — ${e.detail}`)
96
+ .join("; ")
97
+ : typeof data === "string"
98
+ ? data
99
+ : JSON.stringify(data);
100
+ const err = new Error(
101
+ `App Store Connect ${method} ${url.pathname} failed: ${res.status} ${res.statusText} — ${detail}`,
102
+ );
103
+ err.status = res.status;
104
+ err.body = data;
105
+ throw err;
106
+ }
107
+ return data;
108
+ }
109
+
110
+ get(path, query) {
111
+ return this.request("GET", path, { query });
112
+ }
113
+ post(path, body) {
114
+ return this.request("POST", path, { body });
115
+ }
116
+ patch(path, body) {
117
+ return this.request("PATCH", path, { body });
118
+ }
119
+ delete(path) {
120
+ return this.request("DELETE", path);
121
+ }
122
+
123
+ /** Follow `links.next` and concatenate `data` arrays up to `maxPages`. */
124
+ async getAll(path, query, maxPages = 20) {
125
+ let page = await this.get(path, query);
126
+ const all = Array.isArray(page.data) ? [...page.data] : [];
127
+ let pages = 1;
128
+ while (page.links && page.links.next && pages < maxPages) {
129
+ page = await this.request("GET", page.links.next);
130
+ if (Array.isArray(page.data)) all.push(...page.data);
131
+ pages++;
132
+ }
133
+ return all;
134
+ }
135
+
136
+ /**
137
+ * Upload an image to a freshly-reserved upload-asset (screenshot or preview).
138
+ * `uploadOperations` come from the reservation's attributes.
139
+ */
140
+ async uploadAsset(uploadOperations, fileBuffer) {
141
+ for (const op of uploadOperations) {
142
+ const chunk = fileBuffer.subarray(op.offset, op.offset + op.length);
143
+ const headers = {};
144
+ for (const h of op.requestHeaders || []) headers[h.name] = h.value;
145
+ const res = await fetch(op.url, {
146
+ method: op.method,
147
+ headers,
148
+ body: chunk,
149
+ });
150
+ if (!res.ok) {
151
+ const t = await res.text();
152
+ throw new Error(
153
+ `Asset chunk upload failed (${op.method} ${op.url}): ${res.status} ${t}`,
154
+ );
155
+ }
156
+ }
157
+ }
158
+
159
+ static md5(buffer) {
160
+ return createHash("md5").update(buffer).digest("hex");
161
+ }
162
+ }