appstore-api-mcp 1.17.0 → 1.18.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/.env.example +10 -0
- package/CHANGELOG.md +23 -0
- package/docs/TOOLS.md +21 -0
- package/package.json +1 -1
- package/src/guardrails.js +9 -2
- package/src/index.js +232 -1
- package/src/storekit.js +136 -0
package/.env.example
CHANGED
|
@@ -35,3 +35,13 @@ ASC_PRIVATE_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8
|
|
|
35
35
|
# APPSTORE_MCP_SNAPSHOT_DIR=
|
|
36
36
|
# Optional: auto-snapshot an app's text metadata before the first listing edit (a safety net for "revert")
|
|
37
37
|
# APPSTORE_MCP_AUTO_SNAPSHOT=true
|
|
38
|
+
|
|
39
|
+
# --- Retention Messaging API (optional; separate, access-gated StoreKit API) ---
|
|
40
|
+
# Runs on api.storekit.apple.com, NOT the App Store Connect host. Needs the
|
|
41
|
+
# Retention Messaging API entitlement (apply: developer.apple.com/contact/request/retention-messaging-api)
|
|
42
|
+
# and typically an In-App Purchase key (Users and Access → Integrations → In-App Purchase).
|
|
43
|
+
# If these are unset, the retention_* tools reuse the ASC_* key above.
|
|
44
|
+
# RETENTION_KEY_ID=
|
|
45
|
+
# RETENTION_ISSUER_ID=
|
|
46
|
+
# RETENTION_PRIVATE_KEY_PATH=./secrets/RetentionKey_XXXXXXXXXX.p8
|
|
47
|
+
# RETENTION_BUNDLE_ID=technology.fil.SpeakerMate # default app; tools also take a per-call bundleId
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ 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.18.0] - 2026-11-05
|
|
8
|
+
|
|
9
|
+
### Added — Retention Messaging (subscription cancellation flow)
|
|
10
|
+
- Tools for Apple's new **Retention Messaging API** (announced 2026-11-04):
|
|
11
|
+
`retention_doctor`, `retention_upload_message`, `retention_list_messages`,
|
|
12
|
+
`retention_delete_message`, `retention_set_default_message`,
|
|
13
|
+
`retention_get_default_message`, `retention_delete_default_message`,
|
|
14
|
+
`retention_set_realtime_url`, `retention_get_realtime_url`,
|
|
15
|
+
`retention_delete_realtime_url`, and a `retention_request` escape hatch.
|
|
16
|
+
- This is a **separate, access-gated API** on `api.storekit.apple.com` (not the
|
|
17
|
+
App Store Connect host), so it ships as a dedicated `StoreKitClient` (ES256
|
|
18
|
+
JWT, App Store Server API style, per-app `bid`). Configure with `RETENTION_*`
|
|
19
|
+
env (an In-App Purchase key) or it reuses the `ASC_*` key; every call takes a
|
|
20
|
+
`bundleId`, and `sandbox:true` targets the sandbox host. Writes respect
|
|
21
|
+
read-only mode. Message text is validated against Apple's limits (header ≤66,
|
|
22
|
+
body ≤144, ≤5 bullets ≤66) before upload.
|
|
23
|
+
- **Access required:** the Retention Messaging API is entitlement-gated (apply at
|
|
24
|
+
developer.apple.com/contact/request/retention-messaging-api); without it Apple
|
|
25
|
+
returns `404`. The real-time per-subscriber callback is the developer's own
|
|
26
|
+
server, not a tool here. A couple of request-body field names and the exact
|
|
27
|
+
key type are pending live verification once access is granted; the hosts and
|
|
28
|
+
endpoint paths were confirmed to respond (401 without auth).
|
|
29
|
+
|
|
7
30
|
## [1.17.0] - 2026-10-31
|
|
8
31
|
|
|
9
32
|
### Added
|
package/docs/TOOLS.md
CHANGED
|
@@ -709,6 +709,27 @@ you to create the record in the web UI. Same options as `bootstrap_ios_cicd`.
|
|
|
709
709
|
|
|
710
710
|
---
|
|
711
711
|
|
|
712
|
+
## Retention Messaging (subscription cancellation flow)
|
|
713
|
+
|
|
714
|
+
Messages/offers shown when a subscriber moves to cancel. ⚠️ **This is a separate,
|
|
715
|
+
access-gated API** on `api.storekit.apple.com` (not the App Store Connect host).
|
|
716
|
+
It needs the **Retention Messaging API entitlement** (apply at
|
|
717
|
+
[developer.apple.com/contact/request/retention-messaging-api](https://developer.apple.com/contact/request/retention-messaging-api))
|
|
718
|
+
and typically an **In-App Purchase key** (`RETENTION_*` env; otherwise the `ASC_*`
|
|
719
|
+
key is reused). Every call takes the app's **`bundleId`**. Without access, Apple
|
|
720
|
+
returns `404`. The real-time per-subscriber callback is **your own server**, not a tool here.
|
|
721
|
+
|
|
722
|
+
- `retention_doctor(bundleId?)` — **start here**: checks key config and probes whether your account has access.
|
|
723
|
+
- `retention_upload_message(bundleId, header, body, bulletPoints?, image?, …)` — create a message (header ≤66, body ≤144, ≤5 bullets ≤66; PENDING until App Review).
|
|
724
|
+
- `retention_list_messages(bundleId)`, `retention_delete_message(bundleId, messageId)`
|
|
725
|
+
- `retention_set_default_message(bundleId, productId, locale, messageId)` — "assign" a default per product+locale; `retention_get_default_message`, `retention_delete_default_message`
|
|
726
|
+
- `retention_set_realtime_url(bundleId, url)` — register your real-time endpoint (prod needs Apple's performance test first); `retention_get_realtime_url`, `retention_delete_realtime_url`
|
|
727
|
+
- `retention_request(bundleId, method, path, body?)` — escape hatch for any `/inApps/v1/messaging/*` endpoint (image upload, performance test, etc.)
|
|
728
|
+
|
|
729
|
+
All retention tools take an optional `sandbox: true` to use `api.storekit-sandbox.apple.com`.
|
|
730
|
+
|
|
731
|
+
---
|
|
732
|
+
|
|
712
733
|
## Rate limits
|
|
713
734
|
|
|
714
735
|
App Store Connect allows ~3,500 requests/hour and returns `429` when exceeded.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "appstore-api-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.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/guardrails.js
CHANGED
|
@@ -93,6 +93,13 @@ export const WRITE_TOOLS = new Set([
|
|
|
93
93
|
// snapshots
|
|
94
94
|
"restore_app_metadata",
|
|
95
95
|
"restore_screenshots",
|
|
96
|
+
// retention messaging (StoreKit host)
|
|
97
|
+
"retention_upload_message",
|
|
98
|
+
"retention_delete_message",
|
|
99
|
+
"retention_set_default_message",
|
|
100
|
+
"retention_delete_default_message",
|
|
101
|
+
"retention_set_realtime_url",
|
|
102
|
+
"retention_delete_realtime_url",
|
|
96
103
|
]);
|
|
97
104
|
|
|
98
105
|
// High-impact categories with their own opt-out env flags.
|
|
@@ -109,9 +116,9 @@ export function envBool(value, dflt = false) {
|
|
|
109
116
|
return /^(1|true|yes|on)$/i.test(String(value).trim());
|
|
110
117
|
}
|
|
111
118
|
|
|
112
|
-
/** Is this tool call a write? raw_request
|
|
119
|
+
/** Is this tool call a write? raw_request / retention_request are writes unless GET. */
|
|
113
120
|
export function isWriteTool(name, args = {}) {
|
|
114
|
-
if (name === "raw_request") {
|
|
121
|
+
if (name === "raw_request" || name === "retention_request") {
|
|
115
122
|
const m = (args.method || "GET").toUpperCase();
|
|
116
123
|
return m !== "GET";
|
|
117
124
|
}
|
package/src/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
import { detectOverlappingOffers } from "./subscriptions.js";
|
|
38
38
|
import { scanProject } from "./preflight-scan.js";
|
|
39
39
|
import { evaluatePreflight, summarize, VERDICT } from "./preflight-rules.js";
|
|
40
|
+
import { StoreKitClient, validateRetentionMessage, newMessageId } from "./storekit.js";
|
|
40
41
|
|
|
41
42
|
const client = new AppStoreConnectClient({
|
|
42
43
|
keyId: process.env.ASC_KEY_ID,
|
|
@@ -46,6 +47,18 @@ const client = new AppStoreConnectClient({
|
|
|
46
47
|
privateKeyBase64: process.env.ASC_PRIVATE_KEY_BASE64,
|
|
47
48
|
});
|
|
48
49
|
|
|
50
|
+
// Retention Messaging API (StoreKit host, access-gated). Uses a dedicated
|
|
51
|
+
// RETENTION_* key when set, otherwise falls back to the ASC_* key material.
|
|
52
|
+
// A per-app bundle id (JWT `bid` claim) is required on each call.
|
|
53
|
+
const storekit = new StoreKitClient({
|
|
54
|
+
keyId: process.env.RETENTION_KEY_ID || process.env.ASC_KEY_ID,
|
|
55
|
+
issuerId: process.env.RETENTION_ISSUER_ID || process.env.ASC_ISSUER_ID,
|
|
56
|
+
privateKeyPath: process.env.RETENTION_PRIVATE_KEY_PATH || process.env.ASC_PRIVATE_KEY_PATH,
|
|
57
|
+
privateKey: process.env.RETENTION_PRIVATE_KEY || process.env.ASC_PRIVATE_KEY,
|
|
58
|
+
privateKeyBase64: process.env.RETENTION_PRIVATE_KEY_BASE64 || process.env.ASC_PRIVATE_KEY_BASE64,
|
|
59
|
+
bundleId: process.env.RETENTION_BUNDLE_ID,
|
|
60
|
+
});
|
|
61
|
+
|
|
49
62
|
const ok = (data) => ({
|
|
50
63
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
51
64
|
});
|
|
@@ -5854,6 +5867,224 @@ ${a.teamId ? `<key>teamID</key><string>${a.teamId}</string>\n` : ""}<key>uploadS
|
|
|
5854
5867
|
run: async (a) => client.get(`/gameCenterChallenges/${a.id}`, { include: "versions" }),
|
|
5855
5868
|
},
|
|
5856
5869
|
|
|
5870
|
+
// ---- Retention Messaging API (StoreKit host; access-gated) ----
|
|
5871
|
+
// NOTE: This is a SEPARATE API from the rest of this server — it runs on
|
|
5872
|
+
// api.storekit.apple.com, needs the Retention Messaging API entitlement
|
|
5873
|
+
// (apply at developer.apple.com/contact/request/retention-messaging-api),
|
|
5874
|
+
// and typically an In-App Purchase key (set RETENTION_* env; otherwise the
|
|
5875
|
+
// ASC_* key is reused). Every call needs the app's bundleId. Without access,
|
|
5876
|
+
// Apple returns 404 "doesn't have access to this API".
|
|
5877
|
+
{
|
|
5878
|
+
name: "retention_doctor",
|
|
5879
|
+
description:
|
|
5880
|
+
"Check Retention Messaging API wiring: whether a key is configured and (if a bundleId is given) whether this account has access. Read-only. Use this first — it tells you if you still need the entitlement.",
|
|
5881
|
+
inputSchema: {
|
|
5882
|
+
type: "object",
|
|
5883
|
+
properties: {
|
|
5884
|
+
bundleId: { type: "string", description: "The app's bundle id (e.g. technology.fil.SpeakerMate)" },
|
|
5885
|
+
sandbox: { type: "boolean", description: "Use the sandbox host" },
|
|
5886
|
+
},
|
|
5887
|
+
},
|
|
5888
|
+
run: async (a) => {
|
|
5889
|
+
const out = {
|
|
5890
|
+
configured: storekit.configured(),
|
|
5891
|
+
keySource: process.env.RETENTION_KEY_ID ? "RETENTION_* env" : "reusing ASC_* key",
|
|
5892
|
+
bundleIdSource: a.bundleId ? "argument" : process.env.RETENTION_BUNDLE_ID ? "RETENTION_BUNDLE_ID env" : "none",
|
|
5893
|
+
};
|
|
5894
|
+
if (!out.configured) {
|
|
5895
|
+
out.note = "No key configured. Set RETENTION_KEY_ID / RETENTION_ISSUER_ID / a .p8 (an In-App Purchase key), or the ASC_* key.";
|
|
5896
|
+
return out;
|
|
5897
|
+
}
|
|
5898
|
+
const bundleId = a.bundleId || process.env.RETENTION_BUNDLE_ID;
|
|
5899
|
+
if (!bundleId) {
|
|
5900
|
+
out.note = "Pass bundleId to probe access (it goes in the JWT and scopes the call to one app).";
|
|
5901
|
+
return out;
|
|
5902
|
+
}
|
|
5903
|
+
try {
|
|
5904
|
+
await storekit.request("GET", "/inApps/v1/messaging/message", { bundleId, sandbox: a.sandbox });
|
|
5905
|
+
out.access = "granted";
|
|
5906
|
+
out.note = "Retention Messaging API reachable for this app.";
|
|
5907
|
+
} catch (e) {
|
|
5908
|
+
if (e.status === 404) {
|
|
5909
|
+
out.access = "no-access";
|
|
5910
|
+
out.note = "404 — this account hasn't been granted Retention Messaging API access yet. Apply at developer.apple.com/contact/request/retention-messaging-api.";
|
|
5911
|
+
} else if (e.status === 401) {
|
|
5912
|
+
out.access = "bad-key";
|
|
5913
|
+
out.note = "401 — JWT rejected. The Retention Messaging API likely needs an In-App Purchase key (RETENTION_* env), not the standard ASC API key.";
|
|
5914
|
+
} else {
|
|
5915
|
+
out.access = "error";
|
|
5916
|
+
out.error = e.message;
|
|
5917
|
+
}
|
|
5918
|
+
}
|
|
5919
|
+
return out;
|
|
5920
|
+
},
|
|
5921
|
+
},
|
|
5922
|
+
{
|
|
5923
|
+
name: "retention_upload_message",
|
|
5924
|
+
description:
|
|
5925
|
+
"Create/upload a retention message shown in the subscription cancellation flow (text, optional image + up to 5 bullet points). header ≤66 chars, body ≤144, each bullet ≤66, image altText ≤150. A UUID messageId is generated if you don't pass one. Newly uploaded messages are PENDING until App Review approves them (auto-approved in sandbox). Requires the Retention Messaging API entitlement + the app's bundleId. (PUT /inApps/v1/messaging/message/{messageId})",
|
|
5926
|
+
inputSchema: {
|
|
5927
|
+
type: "object",
|
|
5928
|
+
properties: {
|
|
5929
|
+
bundleId: { type: "string" },
|
|
5930
|
+
header: { type: "string", description: "Title line, ≤66 chars" },
|
|
5931
|
+
body: { type: "string", description: "Body text, ≤144 chars" },
|
|
5932
|
+
bulletPoints: { type: "array", items: { type: "string" }, description: "Up to 5, each ≤66 chars" },
|
|
5933
|
+
image: {
|
|
5934
|
+
type: "object",
|
|
5935
|
+
description: "Optional { imageIdentifier, altText } for a text+image message (altText ≤150).",
|
|
5936
|
+
additionalProperties: true,
|
|
5937
|
+
},
|
|
5938
|
+
headerPosition: { type: "string", description: "Default (header above body) or ABOVE_IMAGE" },
|
|
5939
|
+
messageId: { type: "string", description: "Override the generated UUID message identifier" },
|
|
5940
|
+
sandbox: { type: "boolean" },
|
|
5941
|
+
},
|
|
5942
|
+
required: ["bundleId", "header", "body"],
|
|
5943
|
+
},
|
|
5944
|
+
run: async (a) => {
|
|
5945
|
+
const problems = validateRetentionMessage({ header: a.header, body: a.body, bulletPoints: a.bulletPoints, image: a.image });
|
|
5946
|
+
if (problems.length) return { error: "Message does not meet Apple's limits.", problems };
|
|
5947
|
+
const messageId = a.messageId || newMessageId();
|
|
5948
|
+
const body = { header: a.header, body: a.body };
|
|
5949
|
+
if (a.bulletPoints) body.bulletPoints = a.bulletPoints.map((t) => (typeof t === "string" ? { text: t } : t));
|
|
5950
|
+
if (a.image) body.image = a.image;
|
|
5951
|
+
if (a.headerPosition) body.headerPosition = a.headerPosition;
|
|
5952
|
+
const result = await storekit.request("PUT", `/inApps/v1/messaging/message/${messageId}`, {
|
|
5953
|
+
body,
|
|
5954
|
+
bundleId: a.bundleId,
|
|
5955
|
+
sandbox: a.sandbox,
|
|
5956
|
+
});
|
|
5957
|
+
return { messageId, uploaded: true, result };
|
|
5958
|
+
},
|
|
5959
|
+
},
|
|
5960
|
+
{
|
|
5961
|
+
name: "retention_list_messages",
|
|
5962
|
+
description:
|
|
5963
|
+
"List the retention messages for an app and their state (PENDING / APPROVED). Only APPROVED messages are shown to subscribers. Requires the entitlement + bundleId. (GET /inApps/v1/messaging/message)",
|
|
5964
|
+
inputSchema: {
|
|
5965
|
+
type: "object",
|
|
5966
|
+
properties: { bundleId: { type: "string" }, sandbox: { type: "boolean" } },
|
|
5967
|
+
required: ["bundleId"],
|
|
5968
|
+
},
|
|
5969
|
+
run: async (a) => storekit.request("GET", "/inApps/v1/messaging/message", { bundleId: a.bundleId, sandbox: a.sandbox }),
|
|
5970
|
+
},
|
|
5971
|
+
{
|
|
5972
|
+
name: "retention_delete_message",
|
|
5973
|
+
description: "Delete a retention message by its id (frees a slot). (DELETE /inApps/v1/messaging/message/{messageId})",
|
|
5974
|
+
inputSchema: {
|
|
5975
|
+
type: "object",
|
|
5976
|
+
properties: { bundleId: { type: "string" }, messageId: { type: "string" }, sandbox: { type: "boolean" } },
|
|
5977
|
+
required: ["bundleId", "messageId"],
|
|
5978
|
+
},
|
|
5979
|
+
run: async (a) => {
|
|
5980
|
+
await storekit.request("DELETE", `/inApps/v1/messaging/message/${a.messageId}`, { bundleId: a.bundleId, sandbox: a.sandbox });
|
|
5981
|
+
return { deleted: a.messageId };
|
|
5982
|
+
},
|
|
5983
|
+
},
|
|
5984
|
+
{
|
|
5985
|
+
name: "retention_set_default_message",
|
|
5986
|
+
description:
|
|
5987
|
+
"Set the DEFAULT retention message for a subscription product + locale — this is how a message is 'assigned'. Idempotent (call again to replace). Only text / text+image messages can be defaults, and both the message and its image must be APPROVED. Requires the entitlement + bundleId. (PUT /inApps/v1/messaging/default/{productId}/{locale})",
|
|
5988
|
+
inputSchema: {
|
|
5989
|
+
type: "object",
|
|
5990
|
+
properties: {
|
|
5991
|
+
bundleId: { type: "string" },
|
|
5992
|
+
productId: { type: "string", description: "The auto-renewable subscription product id" },
|
|
5993
|
+
locale: { type: "string", description: "e.g. en-US" },
|
|
5994
|
+
messageId: { type: "string", description: "The message identifier to make default" },
|
|
5995
|
+
sandbox: { type: "boolean" },
|
|
5996
|
+
},
|
|
5997
|
+
required: ["bundleId", "productId", "locale", "messageId"],
|
|
5998
|
+
},
|
|
5999
|
+
run: async (a) =>
|
|
6000
|
+
storekit.request("PUT", `/inApps/v1/messaging/default/${a.productId}/${a.locale}`, {
|
|
6001
|
+
// NOTE: exact body key not pinned in Apple's published schema; messageIdentifier is the documented description.
|
|
6002
|
+
body: { messageIdentifier: a.messageId },
|
|
6003
|
+
bundleId: a.bundleId,
|
|
6004
|
+
sandbox: a.sandbox,
|
|
6005
|
+
}),
|
|
6006
|
+
},
|
|
6007
|
+
{
|
|
6008
|
+
name: "retention_get_default_message",
|
|
6009
|
+
description: "Get the default retention message configured for a product + locale. (GET /inApps/v1/messaging/default/{productId}/{locale})",
|
|
6010
|
+
inputSchema: {
|
|
6011
|
+
type: "object",
|
|
6012
|
+
properties: { bundleId: { type: "string" }, productId: { type: "string" }, locale: { type: "string" }, sandbox: { type: "boolean" } },
|
|
6013
|
+
required: ["bundleId", "productId", "locale"],
|
|
6014
|
+
},
|
|
6015
|
+
run: async (a) => storekit.request("GET", `/inApps/v1/messaging/default/${a.productId}/${a.locale}`, { bundleId: a.bundleId, sandbox: a.sandbox }),
|
|
6016
|
+
},
|
|
6017
|
+
{
|
|
6018
|
+
name: "retention_delete_default_message",
|
|
6019
|
+
description: "Remove the default retention message for a product + locale. (DELETE /inApps/v1/messaging/default/{productId}/{locale})",
|
|
6020
|
+
inputSchema: {
|
|
6021
|
+
type: "object",
|
|
6022
|
+
properties: { bundleId: { type: "string" }, productId: { type: "string" }, locale: { type: "string" }, sandbox: { type: "boolean" } },
|
|
6023
|
+
required: ["bundleId", "productId", "locale"],
|
|
6024
|
+
},
|
|
6025
|
+
run: async (a) => {
|
|
6026
|
+
await storekit.request("DELETE", `/inApps/v1/messaging/default/${a.productId}/${a.locale}`, { bundleId: a.bundleId, sandbox: a.sandbox });
|
|
6027
|
+
return { deleted: `${a.productId}/${a.locale}` };
|
|
6028
|
+
},
|
|
6029
|
+
},
|
|
6030
|
+
{
|
|
6031
|
+
name: "retention_set_realtime_url",
|
|
6032
|
+
description:
|
|
6033
|
+
"Register YOUR server's endpoint that Apple calls in real time at cancellation to pick a message/offer per subscriber. Production requires first passing Apple's performance test. The callback handler itself is your backend, not this tool. (PUT /inApps/v1/messaging/realtime/url)",
|
|
6034
|
+
inputSchema: {
|
|
6035
|
+
type: "object",
|
|
6036
|
+
properties: { bundleId: { type: "string" }, url: { type: "string", description: "HTTPS URL of your Get-Retention-Message endpoint" }, sandbox: { type: "boolean" } },
|
|
6037
|
+
required: ["bundleId", "url"],
|
|
6038
|
+
},
|
|
6039
|
+
run: async (a) =>
|
|
6040
|
+
storekit.request("PUT", "/inApps/v1/messaging/realtime/url", {
|
|
6041
|
+
// NOTE: exact body key not pinned in Apple's published schema; it carries the endpoint URL.
|
|
6042
|
+
body: { url: a.url },
|
|
6043
|
+
bundleId: a.bundleId,
|
|
6044
|
+
sandbox: a.sandbox,
|
|
6045
|
+
}),
|
|
6046
|
+
},
|
|
6047
|
+
{
|
|
6048
|
+
name: "retention_get_realtime_url",
|
|
6049
|
+
description: "Get the currently registered real-time retention-messaging URL. (GET /inApps/v1/messaging/realtime/url)",
|
|
6050
|
+
inputSchema: {
|
|
6051
|
+
type: "object",
|
|
6052
|
+
properties: { bundleId: { type: "string" }, sandbox: { type: "boolean" } },
|
|
6053
|
+
required: ["bundleId"],
|
|
6054
|
+
},
|
|
6055
|
+
run: async (a) => storekit.request("GET", "/inApps/v1/messaging/realtime/url", { bundleId: a.bundleId, sandbox: a.sandbox }),
|
|
6056
|
+
},
|
|
6057
|
+
{
|
|
6058
|
+
name: "retention_delete_realtime_url",
|
|
6059
|
+
description: "Remove the registered real-time retention-messaging URL. (DELETE /inApps/v1/messaging/realtime/url)",
|
|
6060
|
+
inputSchema: {
|
|
6061
|
+
type: "object",
|
|
6062
|
+
properties: { bundleId: { type: "string" }, sandbox: { type: "boolean" } },
|
|
6063
|
+
required: ["bundleId"],
|
|
6064
|
+
},
|
|
6065
|
+
run: async (a) => {
|
|
6066
|
+
await storekit.request("DELETE", "/inApps/v1/messaging/realtime/url", { bundleId: a.bundleId, sandbox: a.sandbox });
|
|
6067
|
+
return { deleted: "realtime-url" };
|
|
6068
|
+
},
|
|
6069
|
+
},
|
|
6070
|
+
{
|
|
6071
|
+
name: "retention_request",
|
|
6072
|
+
description:
|
|
6073
|
+
"Escape hatch for the Retention Messaging API (StoreKit host) — call any /inApps/v1/messaging/* endpoint not covered by a dedicated tool (e.g. image upload, performance-test initiate/results). path is the full path starting with /inApps/. Requires the entitlement + bundleId.",
|
|
6074
|
+
inputSchema: {
|
|
6075
|
+
type: "object",
|
|
6076
|
+
properties: {
|
|
6077
|
+
bundleId: { type: "string" },
|
|
6078
|
+
method: { type: "string", enum: ["GET", "POST", "PUT", "DELETE"] },
|
|
6079
|
+
path: { type: "string", description: "e.g. /inApps/v1/messaging/image/{id}" },
|
|
6080
|
+
body: { type: "object", additionalProperties: true },
|
|
6081
|
+
sandbox: { type: "boolean" },
|
|
6082
|
+
},
|
|
6083
|
+
required: ["bundleId", "method", "path"],
|
|
6084
|
+
},
|
|
6085
|
+
run: async (a) => storekit.request(a.method, a.path, { body: a.body, bundleId: a.bundleId, sandbox: a.sandbox }),
|
|
6086
|
+
},
|
|
6087
|
+
|
|
5857
6088
|
// ---- Generic escape hatch ----
|
|
5858
6089
|
{
|
|
5859
6090
|
name: "raw_request",
|
|
@@ -5899,7 +6130,7 @@ Some things a great App Store listing needs live OUTSIDE App Store Connect and c
|
|
|
5899
6130
|
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.`;
|
|
5900
6131
|
|
|
5901
6132
|
const server = new Server(
|
|
5902
|
-
{ name: "appstore-api-mcp", version: "1.
|
|
6133
|
+
{ name: "appstore-api-mcp", version: "1.18.0" },
|
|
5903
6134
|
{ capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS },
|
|
5904
6135
|
);
|
|
5905
6136
|
|
package/src/storekit.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Minimal client for Apple's StoreKit-host APIs (api.storekit.apple.com),
|
|
2
|
+
// used here for the Retention Messaging API. This is a DIFFERENT host, auth and
|
|
3
|
+
// wire format from the App Store Connect JSON:API (see client.js):
|
|
4
|
+
// - host: api.storekit.apple.com (prod) / api.storekit-sandbox.apple.com
|
|
5
|
+
// - auth: ES256 JWT, App Store Server API style (aud "appstoreconnect-v1",
|
|
6
|
+
// plus a `bid` bundle-id claim) — typically an In-App Purchase key
|
|
7
|
+
// - body: plain JSON (NOT the data/type/attributes JSON:API envelope)
|
|
8
|
+
//
|
|
9
|
+
// The Retention Messaging API is access-gated (pre-release, per-app approval).
|
|
10
|
+
// Without the entitlement, calls return 404 "doesn't have access to this API".
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { importPKCS8, SignJWT } from "jose";
|
|
15
|
+
|
|
16
|
+
export const STOREKIT_HOSTS = {
|
|
17
|
+
production: "https://api.storekit.apple.com",
|
|
18
|
+
sandbox: "https://api.storekit-sandbox.apple.com",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class StoreKitClient {
|
|
22
|
+
constructor({ keyId, issuerId, privateKey, privateKeyBase64, privateKeyPath, bundleId } = {}) {
|
|
23
|
+
this.keyId = keyId;
|
|
24
|
+
this.issuerId = issuerId;
|
|
25
|
+
this.defaultBundleId = bundleId || null;
|
|
26
|
+
this._pem =
|
|
27
|
+
privateKey ||
|
|
28
|
+
(privateKeyBase64 ? Buffer.from(privateKeyBase64, "base64").toString("utf8") : null) ||
|
|
29
|
+
(privateKeyPath ? readFileSync(privateKeyPath, "utf8") : null);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True when a key id, issuer id and private key are all present. */
|
|
33
|
+
configured() {
|
|
34
|
+
return !!(this.keyId && this.issuerId && this._pem);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async _token(bundleId) {
|
|
38
|
+
if (!this.configured())
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Retention Messaging API is not configured. Set RETENTION_KEY_ID, RETENTION_ISSUER_ID and a key " +
|
|
41
|
+
"(RETENTION_PRIVATE_KEY_PATH / RETENTION_PRIVATE_KEY / RETENTION_PRIVATE_KEY_BASE64) — typically an " +
|
|
42
|
+
"In-App Purchase key — or let it reuse the ASC_* key. A per-app bundleId is also required.",
|
|
43
|
+
);
|
|
44
|
+
const bid = bundleId || this.defaultBundleId;
|
|
45
|
+
if (!bid)
|
|
46
|
+
throw new Error(
|
|
47
|
+
"bundleId is required for the Retention Messaging API (it goes in the JWT `bid` claim). Pass bundleId, or set RETENTION_BUNDLE_ID.",
|
|
48
|
+
);
|
|
49
|
+
const now = Math.floor(Date.now() / 1000);
|
|
50
|
+
const key = await importPKCS8(this._pem, "ES256");
|
|
51
|
+
return new SignJWT({ bid })
|
|
52
|
+
.setProtectedHeader({ alg: "ES256", kid: this.keyId, typ: "JWT" })
|
|
53
|
+
.setIssuedAt(now)
|
|
54
|
+
.setIssuer(this.issuerId)
|
|
55
|
+
.setExpirationTime(now + 19 * 60)
|
|
56
|
+
.setAudience("appstoreconnect-v1")
|
|
57
|
+
.sign(key);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Make a request against the StoreKit host. `path` starts with /inApps/...
|
|
62
|
+
* opts: { body, bundleId, sandbox }.
|
|
63
|
+
*/
|
|
64
|
+
async request(method, path, { body, bundleId, sandbox } = {}) {
|
|
65
|
+
const host = sandbox ? STOREKIT_HOSTS.sandbox : STOREKIT_HOSTS.production;
|
|
66
|
+
const token = await this._token(bundleId);
|
|
67
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
68
|
+
let payload;
|
|
69
|
+
if (body !== undefined) {
|
|
70
|
+
headers["Content-Type"] = "application/json";
|
|
71
|
+
payload = JSON.stringify(body);
|
|
72
|
+
}
|
|
73
|
+
const res = await fetch(host + path, { method, headers, body: payload });
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
let data = null;
|
|
76
|
+
if (text) {
|
|
77
|
+
try {
|
|
78
|
+
data = JSON.parse(text);
|
|
79
|
+
} catch {
|
|
80
|
+
data = text;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
const detail = typeof data === "string" ? data : data ? JSON.stringify(data) : "";
|
|
85
|
+
const err = new Error(
|
|
86
|
+
`Retention Messaging API ${method} ${path} failed: ${res.status} ${res.statusText}${detail ? " — " + detail : ""}`,
|
|
87
|
+
);
|
|
88
|
+
err.status = res.status;
|
|
89
|
+
err.body = data;
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
return data ?? { ok: true, status: res.status };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- Pure helpers (unit-testable) ----
|
|
97
|
+
|
|
98
|
+
// Apple's documented limits for a retention message.
|
|
99
|
+
export const RETENTION_LIMITS = {
|
|
100
|
+
header: 66,
|
|
101
|
+
body: 144,
|
|
102
|
+
bulletPoints: 5,
|
|
103
|
+
bulletText: 66,
|
|
104
|
+
imageAltText: 150,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Validate the text of a retention message against Apple's limits. Returns an
|
|
109
|
+
* array of human-readable problems (empty = OK). Does not throw.
|
|
110
|
+
*/
|
|
111
|
+
export function validateRetentionMessage({ header, body, bulletPoints, image } = {}) {
|
|
112
|
+
const problems = [];
|
|
113
|
+
if (!header || !header.trim()) problems.push("header is required.");
|
|
114
|
+
else if (header.length > RETENTION_LIMITS.header)
|
|
115
|
+
problems.push(`header is ${header.length} chars; max is ${RETENTION_LIMITS.header}.`);
|
|
116
|
+
if (!body || !body.trim()) problems.push("body is required.");
|
|
117
|
+
else if (body.length > RETENTION_LIMITS.body)
|
|
118
|
+
problems.push(`body is ${body.length} chars; max is ${RETENTION_LIMITS.body}.`);
|
|
119
|
+
if (Array.isArray(bulletPoints)) {
|
|
120
|
+
if (bulletPoints.length > RETENTION_LIMITS.bulletPoints)
|
|
121
|
+
problems.push(`${bulletPoints.length} bullet points; max is ${RETENTION_LIMITS.bulletPoints}.`);
|
|
122
|
+
bulletPoints.forEach((b, i) => {
|
|
123
|
+
const t = typeof b === "string" ? b : b?.text;
|
|
124
|
+
if (t && t.length > RETENTION_LIMITS.bulletText)
|
|
125
|
+
problems.push(`bullet ${i + 1} is ${t.length} chars; max is ${RETENTION_LIMITS.bulletText}.`);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (image && image.altText && image.altText.length > RETENTION_LIMITS.imageAltText)
|
|
129
|
+
problems.push(`image altText is ${image.altText.length} chars; max is ${RETENTION_LIMITS.imageAltText}.`);
|
|
130
|
+
return problems;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A fresh RFC-4122 v4 UUID for a message identifier. */
|
|
134
|
+
export function newMessageId() {
|
|
135
|
+
return randomUUID();
|
|
136
|
+
}
|