appstore-api-mcp 1.16.4 → 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 +52 -0
- package/README.md +7 -1
- package/docs/RECIPES.md +19 -0
- package/docs/TOOLS.md +65 -2
- package/package.json +1 -1
- package/src/guardrails.js +9 -2
- package/src/index.js +389 -4
- package/src/preflight-data/NOTICE.md +25 -0
- package/src/preflight-data/purpose_string_keys.json +703 -0
- package/src/preflight-data/required_reason_apis.json +343 -0
- package/src/preflight-data/sdks_requiring_manifest.json +591 -0
- package/src/preflight-rules.js +463 -0
- package/src/preflight-scan.js +615 -0
- package/src/storekit.js +136 -0
package/src/index.js
CHANGED
|
@@ -35,6 +35,9 @@ import {
|
|
|
35
35
|
currentEffectivePrice,
|
|
36
36
|
} from "./ppp.js";
|
|
37
37
|
import { detectOverlappingOffers } from "./subscriptions.js";
|
|
38
|
+
import { scanProject } from "./preflight-scan.js";
|
|
39
|
+
import { evaluatePreflight, summarize, VERDICT } from "./preflight-rules.js";
|
|
40
|
+
import { StoreKitClient, validateRetentionMessage, newMessageId } from "./storekit.js";
|
|
38
41
|
|
|
39
42
|
const client = new AppStoreConnectClient({
|
|
40
43
|
keyId: process.env.ASC_KEY_ID,
|
|
@@ -44,6 +47,18 @@ const client = new AppStoreConnectClient({
|
|
|
44
47
|
privateKeyBase64: process.env.ASC_PRIVATE_KEY_BASE64,
|
|
45
48
|
});
|
|
46
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
|
+
|
|
47
62
|
const ok = (data) => ({
|
|
48
63
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
49
64
|
});
|
|
@@ -809,6 +824,115 @@ async function diagnoseSubmitBlockers(appId, versionId) {
|
|
|
809
824
|
return issues;
|
|
810
825
|
}
|
|
811
826
|
|
|
827
|
+
/**
|
|
828
|
+
* Collect App Store Connect facts for the preflight rules engine. Every
|
|
829
|
+
* sub-fetch is best-effort: a failure leaves that field undefined so the
|
|
830
|
+
* corresponding check is skipped (reported NA) rather than failing a real
|
|
831
|
+
* submission on a transient read error.
|
|
832
|
+
*/
|
|
833
|
+
async function gatherAscFacts(appId, versionId, { hasLogin } = {}) {
|
|
834
|
+
const facts = { hasLogin };
|
|
835
|
+
try {
|
|
836
|
+
const app = await client.get(`/apps/${appId}`);
|
|
837
|
+
facts.app = { name: app.data?.attributes?.name, primaryLocale: app.data?.attributes?.primaryLocale };
|
|
838
|
+
} catch { /* best-effort */ }
|
|
839
|
+
try {
|
|
840
|
+
const v = await client.get(`/appStoreVersions/${versionId}`, { include: "build" });
|
|
841
|
+
const va = v.data?.attributes || {};
|
|
842
|
+
facts.version = { id: versionId, versionString: va.versionString, appStoreState: va.appStoreState, usesIdfa: va.usesIdfa };
|
|
843
|
+
const buildRel = v.data?.relationships?.build?.data;
|
|
844
|
+
if (!buildRel) facts.build = { attached: false };
|
|
845
|
+
else {
|
|
846
|
+
try {
|
|
847
|
+
const b = await client.get(`/builds/${buildRel.id}`);
|
|
848
|
+
const ba = b.data?.attributes || {};
|
|
849
|
+
facts.build = { attached: true, id: buildRel.id, processingState: ba.processingState, usesNonExemptEncryption: ba.usesNonExemptEncryption };
|
|
850
|
+
} catch {
|
|
851
|
+
facts.build = { attached: true, id: buildRel.id };
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
} catch { /* best-effort */ }
|
|
855
|
+
try {
|
|
856
|
+
const locs = await client.getAll(`/appStoreVersions/${versionId}/appStoreVersionLocalizations`);
|
|
857
|
+
facts.versionLocalizations = locs.map((l) => ({
|
|
858
|
+
locale: l.attributes?.locale,
|
|
859
|
+
description: l.attributes?.description,
|
|
860
|
+
keywords: l.attributes?.keywords,
|
|
861
|
+
whatsNew: l.attributes?.whatsNew,
|
|
862
|
+
supportUrl: l.attributes?.supportUrl,
|
|
863
|
+
marketingUrl: l.attributes?.marketingUrl,
|
|
864
|
+
promotionalText: l.attributes?.promotionalText,
|
|
865
|
+
}));
|
|
866
|
+
const missing = [];
|
|
867
|
+
await mapLimit(locs, 6, async (l) => {
|
|
868
|
+
let shots = 0;
|
|
869
|
+
const sets = await client.getAll(`/appStoreVersionLocalizations/${l.id}/appScreenshotSets`);
|
|
870
|
+
for (const s of sets) shots += (await client.getAll(`/appScreenshotSets/${s.id}/appScreenshots`)).length;
|
|
871
|
+
if (shots === 0) missing.push(l.attributes?.locale);
|
|
872
|
+
});
|
|
873
|
+
facts.localesMissingScreenshots = missing;
|
|
874
|
+
} catch { /* best-effort */ }
|
|
875
|
+
try {
|
|
876
|
+
const infos = await client.get(`/apps/${appId}/appInfos`, { include: "ageRatingDeclaration" });
|
|
877
|
+
const first = (infos.data || [])[0];
|
|
878
|
+
if (first) {
|
|
879
|
+
const il = await client.getAll(`/appInfos/${first.id}/appInfoLocalizations`);
|
|
880
|
+
facts.appInfoLocalizations = il.map((x) => ({
|
|
881
|
+
locale: x.attributes?.locale,
|
|
882
|
+
name: x.attributes?.name,
|
|
883
|
+
subtitle: x.attributes?.subtitle,
|
|
884
|
+
privacyPolicyUrl: x.attributes?.privacyPolicyUrl,
|
|
885
|
+
}));
|
|
886
|
+
}
|
|
887
|
+
// Presence of an ageRatingDeclaration means the age rating has been set.
|
|
888
|
+
facts.ageRatingDeclared = (infos.included || []).some((x) => x.type === "ageRatingDeclarations");
|
|
889
|
+
} catch { /* best-effort */ }
|
|
890
|
+
try {
|
|
891
|
+
const rd = await client.get(`/appStoreVersions/${versionId}/appStoreReviewDetail`);
|
|
892
|
+
facts.reviewDetail = rd.data?.attributes || {};
|
|
893
|
+
} catch {
|
|
894
|
+
// 404 (none set) or any read error → treat as "no review detail on file".
|
|
895
|
+
facts.reviewDetail = null;
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
facts.incompleteAssets = await findIncompleteAssets(versionId);
|
|
899
|
+
} catch { /* best-effort */ }
|
|
900
|
+
try {
|
|
901
|
+
const vs = await client.getAll(`/apps/${appId}/appStoreVersions`, { limit: 10 });
|
|
902
|
+
facts.isUpdate = vs.length > 1;
|
|
903
|
+
} catch { /* best-effort */ }
|
|
904
|
+
return facts;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Run the full preflight: optionally scan a local project, gather App Store
|
|
909
|
+
* Connect facts, and evaluate the guideline rules. `only` keeps findings whose
|
|
910
|
+
* id or guideline ref contains any of the given substrings.
|
|
911
|
+
*/
|
|
912
|
+
async function runPreflight({ appId, versionId, projectRoot, only } = {}) {
|
|
913
|
+
let scan = null;
|
|
914
|
+
if (projectRoot) scan = scanProject(projectRoot);
|
|
915
|
+
let hasLogin;
|
|
916
|
+
if (scan && !scan.error) {
|
|
917
|
+
hasLogin = Boolean(
|
|
918
|
+
scan.featureSignals?.account_creation ||
|
|
919
|
+
scan.featureSignals?.third_party_login ||
|
|
920
|
+
scan.featureSignals?.sign_in_with_apple,
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
let asc;
|
|
924
|
+
if (appId && versionId) asc = await gatherAscFacts(appId, versionId, { hasLogin });
|
|
925
|
+
const result = evaluatePreflight({ scan, asc });
|
|
926
|
+
if (Array.isArray(only) && only.length) {
|
|
927
|
+
const needles = only.map((s) => String(s).toLowerCase());
|
|
928
|
+
result.findings = result.findings.filter(
|
|
929
|
+
(f) => needles.some((n) => f.id.toLowerCase().includes(n) || (f.ref || "").toLowerCase().includes(n)),
|
|
930
|
+
);
|
|
931
|
+
Object.assign(result, summarize(result.findings));
|
|
932
|
+
}
|
|
933
|
+
return result;
|
|
934
|
+
}
|
|
935
|
+
|
|
812
936
|
/** Find screenshots/previews whose assetDeliveryState.state != COMPLETE. */
|
|
813
937
|
async function findIncompleteAssets(versionId) {
|
|
814
938
|
const screenshots = [];
|
|
@@ -2785,13 +2909,15 @@ const tools = [
|
|
|
2785
2909
|
{
|
|
2786
2910
|
name: "submit_for_review",
|
|
2787
2911
|
description:
|
|
2788
|
-
"Submit an App Store version for Apple review. Runs the full flow: create a review submission, add the version, and submit it. NOTE: this sends the app to Apple review — confirm with the user first. platform default IOS.",
|
|
2912
|
+
"Submit an App Store version for Apple review. Runs the full flow: create a review submission, add the version, and submit it. Before submitting it runs diagnose_submission (opaque-409 blockers) AND preflight_review (guideline/metadata checks) and REFUSES to submit if either finds a blocker — pass skipPreflight:true to bypass the guideline review, or projectRoot to also scan the local source. NOTE: this sends the app to Apple review — confirm with the user first. platform default IOS.",
|
|
2789
2913
|
inputSchema: {
|
|
2790
2914
|
type: "object",
|
|
2791
2915
|
properties: {
|
|
2792
2916
|
appId: { type: "string" },
|
|
2793
2917
|
versionId: { type: "string", description: "appStoreVersion id to submit" },
|
|
2794
2918
|
platform: { type: "string", description: "IOS (default), MAC_OS, TV_OS, VISION_OS" },
|
|
2919
|
+
skipPreflight: { type: "boolean", description: "Skip the preflight_review guideline checks (the opaque-409 diagnosis still runs). Default false." },
|
|
2920
|
+
projectRoot: { type: "string", description: "Absolute path to the local app source tree, so preflight also scans the code (optional)." },
|
|
2795
2921
|
},
|
|
2796
2922
|
required: ["appId", "versionId"],
|
|
2797
2923
|
},
|
|
@@ -2806,6 +2932,20 @@ const tools = [
|
|
|
2806
2932
|
"Not submitting — the version has blockers Apple would reject with an opaque 409. Fix these and retry:",
|
|
2807
2933
|
issues: preIssues,
|
|
2808
2934
|
};
|
|
2935
|
+
// Guideline/metadata preflight: block on any FAIL unless explicitly skipped.
|
|
2936
|
+
let preflight = null;
|
|
2937
|
+
if (!a.skipPreflight) {
|
|
2938
|
+
const pf = await runPreflight({ appId: a.appId, versionId: a.versionId, projectRoot: a.projectRoot }).catch(() => null);
|
|
2939
|
+
if (pf && !pf.ready)
|
|
2940
|
+
return {
|
|
2941
|
+
submitted: false,
|
|
2942
|
+
blocked: true,
|
|
2943
|
+
reason:
|
|
2944
|
+
"Not submitting — preflight found blocking guideline/metadata issues. Fix these, or pass skipPreflight:true to override, then retry:",
|
|
2945
|
+
preflight: { status: pf.status, summary: pf.summary, blocking: pf.blocking, meta: pf.meta },
|
|
2946
|
+
};
|
|
2947
|
+
if (pf) preflight = { status: pf.status, summary: pf.summary };
|
|
2948
|
+
}
|
|
2809
2949
|
let subId;
|
|
2810
2950
|
try {
|
|
2811
2951
|
const sub = await client.post("/reviewSubmissions", {
|
|
@@ -2832,7 +2972,7 @@ const tools = [
|
|
|
2832
2972
|
attributes: { submitted: true },
|
|
2833
2973
|
},
|
|
2834
2974
|
});
|
|
2835
|
-
return { submitted: true, reviewSubmissionId: subId, result: submitted };
|
|
2975
|
+
return { submitted: true, reviewSubmissionId: subId, result: submitted, preflight };
|
|
2836
2976
|
} catch (e) {
|
|
2837
2977
|
// On failure, diagnose and surface the likely real cause; clean up the
|
|
2838
2978
|
// half-created (empty) submission so it doesn't orphan.
|
|
@@ -4792,6 +4932,21 @@ ${a.teamId ? `<key>teamID</key><string>${a.teamId}</string>\n` : ""}<key>uploadS
|
|
|
4792
4932
|
return { versionId: a.versionId, ready: issues.length === 0, blockers: issues };
|
|
4793
4933
|
},
|
|
4794
4934
|
},
|
|
4935
|
+
{
|
|
4936
|
+
name: "preflight_review",
|
|
4937
|
+
description:
|
|
4938
|
+
"Pre-submission guideline review. Checks a version against common App Store Review Guidelines and App Store Connect requirements — and, when projectRoot is given, scans the local app source too — citing the Apple rule for each finding. Read-only; writes nothing. Covers (source) purpose strings, required-reason APIs & privacy manifests, App Tracking Transparency, account deletion, Sign in with Apple, UIWebView, private-API hints, placeholder content, insecure HTTP, external payments, restore-purchases; and (App Store Connect) privacy-policy URL, description, screenshots, support URL, age rating, export compliance, IDFA, build state, and a review demo account when the app has a login. Verdicts: PASS / FAIL (blocks submit_for_review) / WARN / MANUAL / ASC / NA. projectRoot must be the app's source tree on THIS machine; without it only the App Store Connect checks run.",
|
|
4939
|
+
inputSchema: {
|
|
4940
|
+
type: "object",
|
|
4941
|
+
properties: {
|
|
4942
|
+
appId: { type: "string" },
|
|
4943
|
+
versionId: { type: "string", description: "appStoreVersion id to review" },
|
|
4944
|
+
projectRoot: { type: "string", description: "Absolute path to the app's Xcode / React Native / Flutter project for local source scanning (optional; skipped if not present on this machine)." },
|
|
4945
|
+
only: { type: "array", items: { type: "string" }, description: 'Keep only findings whose id or guideline ref contains one of these substrings, e.g. ["privacy", "5.1.1", "purpose-string"].' },
|
|
4946
|
+
},
|
|
4947
|
+
},
|
|
4948
|
+
run: async (a) => runPreflight(a),
|
|
4949
|
+
},
|
|
4795
4950
|
{
|
|
4796
4951
|
name: "swap_build",
|
|
4797
4952
|
description:
|
|
@@ -4860,7 +5015,7 @@ ${a.teamId ? `<key>teamID</key><string>${a.teamId}</string>\n` : ""}<key>uploadS
|
|
|
4860
5015
|
{
|
|
4861
5016
|
name: "release_pipeline",
|
|
4862
5017
|
description:
|
|
4863
|
-
"Run the repeated pre-submit sequence for a version: optionally attach a build, run diagnose_submission, and (only if there are no blockers and submit:true) submit for review. Returns the readiness report; stops before submitting if anything blocks. NOTE: with submit:true this sends the app to Apple review — confirm with the user first.",
|
|
5018
|
+
"Run the repeated pre-submit sequence for a version: optionally attach a build, run diagnose_submission AND preflight_review, and (only if there are no blockers from either and submit:true) submit for review. Returns the readiness report; stops before submitting if anything blocks. Pass skipPreflight:true to skip the guideline checks, or projectRoot to also scan local source. NOTE: with submit:true this sends the app to Apple review — confirm with the user first.",
|
|
4864
5019
|
inputSchema: {
|
|
4865
5020
|
type: "object",
|
|
4866
5021
|
properties: {
|
|
@@ -4869,6 +5024,8 @@ ${a.teamId ? `<key>teamID</key><string>${a.teamId}</string>\n` : ""}<key>uploadS
|
|
|
4869
5024
|
buildNumber: { type: "string", description: "Optional: attach this build first" },
|
|
4870
5025
|
buildId: { type: "string" },
|
|
4871
5026
|
submit: { type: "boolean", description: "Submit for review if there are no blockers (default false)" },
|
|
5027
|
+
skipPreflight: { type: "boolean", description: "Skip the preflight_review guideline checks (default false)." },
|
|
5028
|
+
projectRoot: { type: "string", description: "Absolute path to the local app source tree for preflight source scanning (optional)." },
|
|
4872
5029
|
},
|
|
4873
5030
|
required: ["appId", "versionId"],
|
|
4874
5031
|
},
|
|
@@ -4889,6 +5046,16 @@ ${a.teamId ? `<key>teamID</key><string>${a.teamId}</string>\n` : ""}<key>uploadS
|
|
|
4889
5046
|
out.note = "Blockers found — not submitting. Fix them and re-run.";
|
|
4890
5047
|
return out;
|
|
4891
5048
|
}
|
|
5049
|
+
if (!a.skipPreflight) {
|
|
5050
|
+
const pf = await runPreflight({ appId: a.appId, versionId: a.versionId, projectRoot: a.projectRoot }).catch(() => null);
|
|
5051
|
+
if (pf) out.preflight = { status: pf.status, summary: pf.summary, blocking: pf.blocking, meta: pf.meta };
|
|
5052
|
+
if (pf && !pf.ready) {
|
|
5053
|
+
out.submitted = false;
|
|
5054
|
+
out.ready = false;
|
|
5055
|
+
out.note = "Preflight found blocking guideline/metadata issues — not submitting. Fix them (or pass skipPreflight:true) and re-run.";
|
|
5056
|
+
return out;
|
|
5057
|
+
}
|
|
5058
|
+
}
|
|
4892
5059
|
if (!a.submit) {
|
|
4893
5060
|
out.submitted = false;
|
|
4894
5061
|
out.note = "Ready. Re-run with submit:true (after user confirmation) to submit for review.";
|
|
@@ -5700,6 +5867,224 @@ ${a.teamId ? `<key>teamID</key><string>${a.teamId}</string>\n` : ""}<key>uploadS
|
|
|
5700
5867
|
run: async (a) => client.get(`/gameCenterChallenges/${a.id}`, { include: "versions" }),
|
|
5701
5868
|
},
|
|
5702
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
|
+
|
|
5703
6088
|
// ---- Generic escape hatch ----
|
|
5704
6089
|
{
|
|
5705
6090
|
name: "raw_request",
|
|
@@ -5745,7 +6130,7 @@ Some things a great App Store listing needs live OUTSIDE App Store Connect and c
|
|
|
5745
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.`;
|
|
5746
6131
|
|
|
5747
6132
|
const server = new Server(
|
|
5748
|
-
{ name: "appstore-api-mcp", version: "1.
|
|
6133
|
+
{ name: "appstore-api-mcp", version: "1.18.0" },
|
|
5749
6134
|
{ capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS },
|
|
5750
6135
|
);
|
|
5751
6136
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Preflight data — attribution
|
|
2
|
+
|
|
3
|
+
The JSON data files in this directory are derived from the
|
|
4
|
+
**app-store-preflight** project by Ilan Uzan, used under the MIT License:
|
|
5
|
+
|
|
6
|
+
- Source: https://github.com/ilan-uzan/app-store-preflight
|
|
7
|
+
- License: MIT (Copyright (c) 2026 Ilan Uzan)
|
|
8
|
+
|
|
9
|
+
Files:
|
|
10
|
+
|
|
11
|
+
- `required_reason_apis.json` — Apple's required-reason API categories and the
|
|
12
|
+
symbols used as grep patterns (generated from Apple's DocC JSON).
|
|
13
|
+
- `purpose_string_keys.json` — Info.plist purpose-string (`*UsageDescription`)
|
|
14
|
+
keys and their Apple descriptions.
|
|
15
|
+
- `sdks_requiring_manifest.json` — SDKs Apple lists as requiring a privacy
|
|
16
|
+
manifest + signature.
|
|
17
|
+
|
|
18
|
+
These lists mirror Apple's published requirements and are bundled so the
|
|
19
|
+
preflight checks run without network access. They are data, not Apple's
|
|
20
|
+
guideline text; each is maintained upstream against Apple's own pages.
|
|
21
|
+
|
|
22
|
+
The local-project scanner (`src/preflight-scan.js`) is a JavaScript port of the
|
|
23
|
+
upstream `scripts/scan_project.py`. The rules that turn scanner + App Store
|
|
24
|
+
Connect facts into guideline-cited findings (`src/preflight-rules.js`) are
|
|
25
|
+
original to this project.
|