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