@orellbuehler/testflight-mcp 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Orell Bühler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,202 @@
1
+ # testflight-mcp
2
+
3
+ [![npm](https://img.shields.io/npm/v/@orellbuehler/testflight-mcp.svg)](https://www.npmjs.com/package/@orellbuehler/testflight-mcp)
4
+ [![CI](https://github.com/OrellBuehler/testflight-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/OrellBuehler/testflight-mcp/actions/workflows/ci.yml)
5
+ [![node](https://img.shields.io/node/v/@orellbuehler/testflight-mcp.svg)](https://nodejs.org)
6
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
7
+
8
+ MCP server for **TestFlight** and **App Store Connect** that exposes the
9
+ [App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi) as tools for AI
10
+ agents.
11
+
12
+ Its focus is **TestFlight beta feedback retrieval** — pulling the screenshot feedback (with the
13
+ tester's comment), crash feedback and crash logs your testers submit — plus the surrounding context
14
+ an agent needs to make sense of it: apps, builds, beta testers/groups, analytics & sales reports,
15
+ provisioning, and App Store metadata. It talks **only to the official, documented API** using a
16
+ standard App Store Connect API key (ES256 JWT), and is **read-only**.
17
+
18
+ > It deliberately does **not** scrape App Store Connect with your Apple ID / password (no headless
19
+ > browser, no internal `iris` API) and does **not** send email to testers. Some third-party
20
+ > TestFlight servers do; this one stays on the supported API.
21
+
22
+ ## Install
23
+
24
+ The package is published as
25
+ [`@orellbuehler/testflight-mcp`](https://www.npmjs.com/package/@orellbuehler/testflight-mcp) and runs
26
+ directly with `npx` — no clone or build needed:
27
+
28
+ ```bash
29
+ claude mcp add testflight \
30
+ --env ASC_KEY_ID=ABCD123456 \
31
+ --env ASC_ISSUER_ID=12a3b456-7890-1234-5678-9abcdef01234 \
32
+ --env ASC_PRIVATE_KEY_PATH=/path/to/AuthKey_ABCD123456.p8 \
33
+ -- npx -y @orellbuehler/testflight-mcp
34
+ ```
35
+
36
+ For any other MCP client, run the package directly — `npx -y @orellbuehler/testflight-mcp` with the
37
+ env vars below set. Requires Node.js 20+.
38
+
39
+ ## Getting an API key
40
+
41
+ The server authenticates with an **App Store Connect API key** (a `.p8` file plus a key ID and issuer
42
+ ID), the same credentials `fastlane`/`altool` use:
43
+
44
+ 1. In [App Store Connect](https://appstoreconnect.apple.com), go to **Users and Access →
45
+ Integrations → App Store Connect API** (Team Keys).
46
+ 2. **Generate** a key. A role of **App Manager** (or **Admin**) covers TestFlight feedback; **Finance**
47
+ is additionally required for sales/finance reports.
48
+ 3. Download the `AuthKey_XXXXXXXXXX.p8` (you can only download it once — keep it safe), and note the
49
+ **Key ID** and the team **Issuer ID** shown above the keys table.
50
+
51
+ Provide the key either as a file path (`ASC_PRIVATE_KEY_PATH`) or inline (`ASC_PRIVATE_KEY`, newlines
52
+ may be escaped as `\n`). The key is read locally and used only to sign short-lived request tokens.
53
+
54
+ ## Configuration
55
+
56
+ | Variable | Required | Description |
57
+ | ---------------------- | -------- | ------------------------------------------------------------------------------------- |
58
+ | `ASC_KEY_ID` | yes | App Store Connect API **Key ID**. |
59
+ | `ASC_ISSUER_ID` | yes | App Store Connect API **Issuer ID** (per team). |
60
+ | `ASC_PRIVATE_KEY_PATH` | yes\* | Path to the downloaded `.p8` private key file. |
61
+ | `ASC_PRIVATE_KEY` | yes\* | The `.p8` contents inline (alternative to the path; `\n` escapes are unescaped). |
62
+ | `ASC_VENDOR_NUMBER` | no | Vendor number; required only for `download_sales_report` / `download_finance_report`. |
63
+
64
+ \* Provide **either** `ASC_PRIVATE_KEY_PATH` **or** `ASC_PRIVATE_KEY`.
65
+
66
+ ## Usage with Claude Code
67
+
68
+ Add the server to `~/.claude/settings.json` (or a project `.mcp.json`):
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "testflight": {
74
+ "command": "npx",
75
+ "args": ["-y", "@orellbuehler/testflight-mcp"],
76
+ "env": {
77
+ "ASC_KEY_ID": "ABCD123456",
78
+ "ASC_ISSUER_ID": "12a3b456-7890-1234-5678-9abcdef01234",
79
+ "ASC_PRIVATE_KEY_PATH": "/path/to/AuthKey_ABCD123456.p8"
80
+ }
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ If you built from source instead, use `"command": "node"` with
87
+ `"args": ["/path/to/testflight-mcp/dist/index.js"]`. Restart Claude Code and verify with
88
+ `claude mcp list` (should show `testflight ✓ connected`) or `/mcp` inside a session.
89
+
90
+ ## Tools
91
+
92
+ Start from `list_apps` to get an `app_id`, then drill into feedback. All tools are read-only.
93
+
94
+ **TestFlight feedback**
95
+
96
+ | Tool | Description |
97
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
98
+ | `list_screenshot_feedback` | Screenshot feedback for an app: tester comment, screenshot URLs, device/OS, tester, build. Filter by build/platform/device/OS/tester. |
99
+ | `list_crash_feedback` | Crash feedback for an app: comment, device/OS, tester, build, crash-log reference. |
100
+ | `get_screenshot_feedback` | One screenshot submission; optionally returns the first screenshot inline as an image. |
101
+ | `get_crash_feedback` | One crash submission with full metadata and crash-log reference. |
102
+ | `get_crash_log` | Download the crash log text for a crash submission. |
103
+
104
+ **Apps & builds**
105
+
106
+ | Tool | Description |
107
+ | ----------------------- | -------------------------------------------------------------------------- |
108
+ | `list_apps` | Apps in the account (id, name, bundleId, sku). Filter by bundle ID. |
109
+ | `get_app` | One app by ID. |
110
+ | `list_builds` | TestFlight builds for an app (version, processing state, expiry). |
111
+ | `get_build` | One build with its pre-release version. |
112
+ | `list_customer_reviews` | Public App Store reviews for a released app (distinct from beta feedback). |
113
+
114
+ **Beta testers & groups**
115
+
116
+ | Tool | Description |
117
+ | -------------------- | -------------------------------------------------------------------------- |
118
+ | `list_beta_groups` | Beta groups for an app (internal/external, public link, feedback enabled). |
119
+ | `list_beta_testers` | Beta testers (name, email, invite type, state). Filter by app/group/email. |
120
+ | `list_group_testers` | Testers in a specific beta group. |
121
+
122
+ **Analytics & reports**
123
+
124
+ | Tool | Description |
125
+ | ----------------------------------- | ---------------------------------------------------------------------------- |
126
+ | `create_analytics_report_request` | Request an analytics report (the required first step). Returns a request ID. |
127
+ | `list_analytics_reports` | Reports available for a request, optionally filtered by category. |
128
+ | `list_analytics_report_segments` | Downloadable segments of a report (presigned URLs). |
129
+ | `download_analytics_report_segment` | Download + decompress a segment to CSV/TSV text. |
130
+ | `download_sales_report` | Sales & Trends report as CSV (needs `ASC_VENDOR_NUMBER`). |
131
+ | `download_finance_report` | Financial report as CSV (needs `ASC_VENDOR_NUMBER` and a region code). |
132
+
133
+ **Provisioning & devices**
134
+
135
+ | Tool | Description |
136
+ | ------------------- | --------------------------------------------------- |
137
+ | `list_devices` | Registered devices (name, platform, UDID, status). |
138
+ | `list_certificates` | Signing certificates (type, name, serial, expiry). |
139
+ | `list_profiles` | Provisioning profiles with their bundle ID. |
140
+ | `list_bundle_ids` | Registered bundle IDs (identifier, name, platform). |
141
+
142
+ **App metadata & localizations**
143
+
144
+ | Tool | Description |
145
+ | -------------------------------------- | -------------------------------------------------------------- |
146
+ | `list_app_store_versions` | App Store versions for an app (version, platform, state). |
147
+ | `list_app_store_version_localizations` | Per-locale metadata (description, keywords, what's new, URLs). |
148
+ | `get_app_store_version_localization` | One localization by ID. |
149
+
150
+ ## Notes & caveats
151
+
152
+ - **Read-only.** The server cannot add/remove testers, edit metadata, or submit apps. The only `POST`
153
+ is `create_analytics_report_request`, which requests an analytics snapshot so the data can be read;
154
+ it does not change your app.
155
+ - **TestFlight feedback** requires builds uploaded with feedback enabled and is retained by Apple for
156
+ a limited window (~90 days). The tester's typed comment is the `comment` field on a screenshot
157
+ submission.
158
+ - **Crash logs** are resolved from the submission's crash-log URL and downloaded as text. If Apple
159
+ exposes no download URL for a given submission, `get_crash_log` returns the raw attributes so you
160
+ can see what's available.
161
+ - **Reports** (`download_sales_report` / `download_finance_report`) are gzipped CSV decompressed for
162
+ you, and need the **Finance** role on the API key plus `ASC_VENDOR_NUMBER`.
163
+ - **Your data goes to the agent/LLM.** Feedback includes tester names, emails and device details. Use
164
+ an API key scoped to the access you actually want.
165
+
166
+ ## Development
167
+
168
+ ```bash
169
+ npm install
170
+ npm run build # tsc -> dist/
171
+ npm test # vitest run
172
+ npm run typecheck # tsc --noEmit
173
+ npm run lint # eslint src
174
+ npm run format:check # prettier --check .
175
+ ```
176
+
177
+ Run a single test file:
178
+
179
+ ```bash
180
+ npx vitest run src/__tests__/feedback.test.ts
181
+ ```
182
+
183
+ ## CI / Releasing
184
+
185
+ - **CI** (`.github/workflows/ci.yml`) runs on every push to `main` and on pull requests:
186
+ `format:check`, `lint`, `typecheck` (once) and `test` + `build` on Node 20 and 22.
187
+ - **Publish** (`.github/workflows/publish.yml`) runs when a GitHub Release is published. It builds,
188
+ tests, and publishes to npm using [trusted publishing](https://docs.npmjs.com/trusted-publishers)
189
+ (OIDC) — **no `NPM_TOKEN` secret required**, with provenance generated automatically. It skips
190
+ publishing if that version is already on npm.
191
+
192
+ Cut a release:
193
+
194
+ ```bash
195
+ npm version patch # bumps package.json + creates a vX.Y.Z tag (use minor/major as needed)
196
+ git push --follow-tags
197
+ gh release create "v$(node -p "require('./package.json').version")" --generate-notes
198
+ ```
199
+
200
+ ## License
201
+
202
+ [MIT](./LICENSE) © Orell Bühler
@@ -0,0 +1,126 @@
1
+ import { gunzipSync } from "node:zlib";
2
+ export const BASE_URL = "https://api.appstoreconnect.apple.com/v1";
3
+ export class AppStoreConnectClient {
4
+ tokenProvider;
5
+ timeoutMs;
6
+ constructor(tokenProvider, timeoutMs = 30000) {
7
+ this.tokenProvider = tokenProvider;
8
+ this.timeoutMs = timeoutMs;
9
+ }
10
+ buildUrl(path, params) {
11
+ const url = new URL(path.startsWith("http") ? path : `${BASE_URL}${path}`);
12
+ if (params) {
13
+ for (const [k, v] of Object.entries(params)) {
14
+ if (v === undefined || v === null)
15
+ continue;
16
+ if (Array.isArray(v)) {
17
+ if (v.length === 0)
18
+ continue;
19
+ url.searchParams.set(k, v.join(","));
20
+ }
21
+ else {
22
+ url.searchParams.set(k, String(v));
23
+ }
24
+ }
25
+ }
26
+ return url.toString();
27
+ }
28
+ async request(url, options = {}) {
29
+ const token = await this.tokenProvider();
30
+ let res;
31
+ try {
32
+ res = await fetch(url, {
33
+ ...options,
34
+ headers: {
35
+ Authorization: `Bearer ${token}`,
36
+ Accept: "application/json",
37
+ ...(typeof options.body === "string" ? { "Content-Type": "application/json" } : {}),
38
+ ...(options.headers ?? {}),
39
+ },
40
+ signal: options.signal ?? AbortSignal.timeout(this.timeoutMs),
41
+ });
42
+ }
43
+ catch (e) {
44
+ if (e instanceof DOMException && e.name === "TimeoutError") {
45
+ throw new Error(`App Store Connect request timed out after ${this.timeoutMs}ms`, {
46
+ cause: e,
47
+ });
48
+ }
49
+ throw e;
50
+ }
51
+ if (!res.ok) {
52
+ const body = await res.text();
53
+ throw new Error(`${res.status} ${res.statusText}: ${body}`);
54
+ }
55
+ return res;
56
+ }
57
+ async get(path, params) {
58
+ const res = await this.request(this.buildUrl(path, params));
59
+ return res.json();
60
+ }
61
+ async getAll(path, params, maxPages = 5) {
62
+ const data = [];
63
+ const included = [];
64
+ let next;
65
+ let page = 0;
66
+ do {
67
+ const res = next ? await this.get(next) : await this.get(path, params);
68
+ if (Array.isArray(res.data))
69
+ data.push(...res.data);
70
+ else if (res.data)
71
+ data.push(res.data);
72
+ if (res.included)
73
+ included.push(...res.included);
74
+ next = res.links?.next;
75
+ page++;
76
+ } while (next && page < maxPages);
77
+ return { data, included };
78
+ }
79
+ async post(path, body) {
80
+ const res = await this.request(this.buildUrl(path), {
81
+ method: "POST",
82
+ body: JSON.stringify(body),
83
+ });
84
+ return res.json();
85
+ }
86
+ async downloadText(url) {
87
+ const res = await fetch(url, { signal: AbortSignal.timeout(this.timeoutMs) });
88
+ if (!res.ok)
89
+ throw new Error(`${res.status} ${res.statusText}: failed to download ${url}`);
90
+ return res.text();
91
+ }
92
+ async downloadBinary(url) {
93
+ const res = await fetch(url, { signal: AbortSignal.timeout(this.timeoutMs) });
94
+ if (!res.ok)
95
+ throw new Error(`${res.status} ${res.statusText}: failed to download ${url}`);
96
+ const buf = Buffer.from(await res.arrayBuffer());
97
+ return {
98
+ base64: buf.toString("base64"),
99
+ mimeType: res.headers.get("content-type") ?? "image/png",
100
+ };
101
+ }
102
+ async downloadGzipText(url) {
103
+ const res = await fetch(url, { signal: AbortSignal.timeout(this.timeoutMs) });
104
+ if (!res.ok)
105
+ throw new Error(`${res.status} ${res.statusText}: failed to download ${url}`);
106
+ const buf = Buffer.from(await res.arrayBuffer());
107
+ try {
108
+ return gunzipSync(buf).toString("utf-8");
109
+ }
110
+ catch {
111
+ return buf.toString("utf-8");
112
+ }
113
+ }
114
+ async getGzippedReport(path, params) {
115
+ const res = await this.request(this.buildUrl(path, params), {
116
+ headers: { Accept: "application/a-gzip, application/json" },
117
+ });
118
+ const buf = Buffer.from(await res.arrayBuffer());
119
+ try {
120
+ return gunzipSync(buf).toString("utf-8");
121
+ }
122
+ catch {
123
+ return buf.toString("utf-8");
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,41 @@
1
+ export function ok(data) {
2
+ const text = typeof data === "string" ? data : JSON.stringify(data);
3
+ return { content: [{ type: "text", text }] };
4
+ }
5
+ export function err(e) {
6
+ return { content: [{ type: "text", text: String(e) }], isError: true };
7
+ }
8
+ export function imageResult(base64, mimeType, caption) {
9
+ const content = [];
10
+ if (caption)
11
+ content.push({ type: "text", text: caption });
12
+ content.push({ type: "image", data: base64, mimeType });
13
+ return { content };
14
+ }
15
+ export function singleRef(rel) {
16
+ const data = rel?.data;
17
+ if (!data || Array.isArray(data))
18
+ return null;
19
+ return data;
20
+ }
21
+ export function findIncluded(included, ref) {
22
+ if (!ref)
23
+ return null;
24
+ return included.find((r) => r.type === ref.type && r.id === ref.id) ?? null;
25
+ }
26
+ export function flattenResource(resource) {
27
+ if (!resource)
28
+ return null;
29
+ return { id: resource.id, type: resource.type, ...resource.attributes };
30
+ }
31
+ export function shapeResource(resource, included = [], options = {}) {
32
+ const out = {
33
+ id: resource.id,
34
+ type: resource.type,
35
+ ...resource.attributes,
36
+ };
37
+ for (const name of options.relationships ?? []) {
38
+ out[name] = flattenResource(findIncluded(included, singleRef(resource.relationships?.[name])));
39
+ }
40
+ return out;
41
+ }
@@ -0,0 +1,33 @@
1
+ import { SignJWT, importPKCS8 } from "jose";
2
+ import { readFile } from "node:fs/promises";
3
+ async function loadKey(auth) {
4
+ if (auth.privateKey && auth.privateKey.trim()) {
5
+ return auth.privateKey.replace(/\\n/g, "\n");
6
+ }
7
+ if (auth.privateKeyPath) {
8
+ return readFile(auth.privateKeyPath, "utf-8");
9
+ }
10
+ throw new Error("No App Store Connect private key configured");
11
+ }
12
+ export function createTokenProvider(auth, nowMs = Date.now) {
13
+ let cached = null;
14
+ let pem = null;
15
+ return async () => {
16
+ const now = Math.floor(nowMs() / 1000);
17
+ if (cached && cached.expiresAt > now + 60)
18
+ return cached.token;
19
+ if (pem === null)
20
+ pem = await loadKey(auth);
21
+ const key = await importPKCS8(pem, "ES256");
22
+ const expiresAt = now + 20 * 60;
23
+ const token = await new SignJWT({})
24
+ .setProtectedHeader({ alg: "ES256", kid: auth.keyId, typ: "JWT" })
25
+ .setIssuer(auth.issuerId)
26
+ .setIssuedAt(now)
27
+ .setExpirationTime(expiresAt)
28
+ .setAudience("appstoreconnect-v1")
29
+ .sign(key);
30
+ cached = { token, expiresAt };
31
+ return token;
32
+ };
33
+ }
package/dist/config.js ADDED
@@ -0,0 +1,20 @@
1
+ import { AppStoreConnectClient } from "./asc/client.js";
2
+ import { createTokenProvider } from "./asc/jwt.js";
3
+ const keyId = (process.env.ASC_KEY_ID || "").trim();
4
+ const issuerId = (process.env.ASC_ISSUER_ID || "").trim();
5
+ const privateKey = process.env.ASC_PRIVATE_KEY;
6
+ const privateKeyPath = (process.env.ASC_PRIVATE_KEY_PATH || "").trim() || undefined;
7
+ const vendorNumber = (process.env.ASC_VENDOR_NUMBER || "").trim() || undefined;
8
+ if (!keyId || !issuerId || (!privateKey && !privateKeyPath)) {
9
+ console.error("ASC_KEY_ID, ASC_ISSUER_ID and one of ASC_PRIVATE_KEY / ASC_PRIVATE_KEY_PATH are required. " +
10
+ "Create an App Store Connect API key under Users and Access > Integrations > App Store Connect API.");
11
+ process.exit(1);
12
+ }
13
+ export const config = {
14
+ keyId,
15
+ issuerId,
16
+ vendorNumber,
17
+ transport: "stdio",
18
+ };
19
+ const tokenProvider = createTokenProvider({ keyId, issuerId, privateKey, privateKeyPath });
20
+ export const client = new AppStoreConnectClient(tokenProvider);
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { client, config } from "./config.js";
4
+ import { createServer } from "./server.js";
5
+ const server = createServer(client, config.vendorNumber);
6
+ const transport = new StdioServerTransport();
7
+ try {
8
+ await server.connect(transport);
9
+ }
10
+ catch (e) {
11
+ console.error(`Failed to start testflight-mcp: ${String(e)}`);
12
+ process.exit(1);
13
+ }
package/dist/server.js ADDED
@@ -0,0 +1,17 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { registerFeedbackTools } from "./tools/feedback.js";
3
+ import { registerAppTools } from "./tools/apps.js";
4
+ import { registerTesterTools } from "./tools/testers.js";
5
+ import { registerAnalyticsTools } from "./tools/analytics.js";
6
+ import { registerProvisioningTools } from "./tools/provisioning.js";
7
+ import { registerMetadataTools } from "./tools/metadata.js";
8
+ export function createServer(client, vendorNumber) {
9
+ const server = new McpServer({ name: "testflight-mcp", version: "0.1.0" });
10
+ registerFeedbackTools(server, client);
11
+ registerAppTools(server, client);
12
+ registerTesterTools(server, client);
13
+ registerAnalyticsTools(server, client, vendorNumber);
14
+ registerProvisioningTools(server, client);
15
+ registerMetadataTools(server, client);
16
+ return server;
17
+ }
@@ -0,0 +1,147 @@
1
+ import { z } from "zod";
2
+ import { ok, err, flattenResource } from "../asc/format.js";
3
+ export function registerAnalyticsTools(server, client, defaultVendorNumber) {
4
+ server.tool("create_analytics_report_request", "Create an analytics report request for an app — the required first step to read App Store analytics. Returns a reportRequestId to pass to list_analytics_reports. ONE_TIME_SNAPSHOT requests the latest data once; ONGOING accrues daily data. This creates a request resource but does not modify the app.", {
5
+ app_id: z.string().describe("App Store Connect app ID"),
6
+ access_type: z
7
+ .enum(["ONE_TIME_SNAPSHOT", "ONGOING"])
8
+ .optional()
9
+ .describe("Report access type (default: ONE_TIME_SNAPSHOT)"),
10
+ }, async ({ app_id, access_type }) => {
11
+ try {
12
+ const res = await client.post("/analyticsReportRequests", {
13
+ data: {
14
+ type: "analyticsReportRequests",
15
+ attributes: { accessType: access_type ?? "ONE_TIME_SNAPSHOT" },
16
+ relationships: { app: { data: { type: "apps", id: app_id } } },
17
+ },
18
+ });
19
+ return ok(flattenResource(res.data));
20
+ }
21
+ catch (e) {
22
+ return err(e);
23
+ }
24
+ });
25
+ server.tool("list_analytics_reports", "List the analytics reports available for a report request (name, category, instances). Optionally filter by category.", {
26
+ report_request_id: z
27
+ .string()
28
+ .describe("reportRequestId from create_analytics_report_request"),
29
+ category: z
30
+ .enum([
31
+ "APP_STORE_ENGAGEMENT",
32
+ "APP_STORE_COMMERCE",
33
+ "APP_USAGE",
34
+ "FRAMEWORKS_USAGE",
35
+ "PERFORMANCE",
36
+ ])
37
+ .optional()
38
+ .describe("Filter reports by category"),
39
+ limit: z.number().int().min(1).max(200).optional().describe("Max reports (default: 100)"),
40
+ }, async ({ report_request_id, category, limit }) => {
41
+ try {
42
+ const params = { limit: limit ?? 100 };
43
+ if (category)
44
+ params["filter[category]"] = category;
45
+ const { data } = await client.getAll(`/analyticsReportRequests/${encodeURIComponent(report_request_id)}/reports`, params);
46
+ return ok({ count: data.length, reports: data.map((d) => flattenResource(d)) });
47
+ }
48
+ catch (e) {
49
+ return err(e);
50
+ }
51
+ });
52
+ server.tool("list_analytics_report_segments", "List the downloadable segments of an analytics report (each has a presigned url, size and checksum). Pass a segment url to download_analytics_report_segment.", {
53
+ report_id: z.string().describe("Analytics report ID from list_analytics_reports"),
54
+ limit: z.number().int().min(1).max(200).optional().describe("Max segments (default: 100)"),
55
+ }, async ({ report_id, limit }) => {
56
+ try {
57
+ const { data } = await client.getAll(`/analyticsReports/${encodeURIComponent(report_id)}/segments`, { limit: limit ?? 100 });
58
+ return ok({ count: data.length, segments: data.map((d) => flattenResource(d)) });
59
+ }
60
+ catch (e) {
61
+ return err(e);
62
+ }
63
+ });
64
+ server.tool("download_analytics_report_segment", "Download and decompress an analytics report segment from its presigned url. Returns the report data as CSV/TSV text.", { segment_url: z.string().describe("The 'url' field of an analytics report segment") }, async ({ segment_url }) => {
65
+ try {
66
+ return ok(await client.downloadGzipText(segment_url));
67
+ }
68
+ catch (e) {
69
+ return err(e);
70
+ }
71
+ });
72
+ server.tool("download_sales_report", "Download a Sales and Trends report as CSV text (decompressed from gzip). Requires a vendor number (from ASC_VENDOR_NUMBER or passed explicitly).", {
73
+ report_date: z
74
+ .string()
75
+ .describe("Report date: 'YYYY-MM-DD' (DAILY), 'YYYY-MM-DD' week end (WEEKLY), 'YYYY-MM' (MONTHLY), 'YYYY' (YEARLY)"),
76
+ frequency: z
77
+ .enum(["DAILY", "WEEKLY", "MONTHLY", "YEARLY"])
78
+ .optional()
79
+ .describe("Report frequency (default: DAILY)"),
80
+ report_type: z
81
+ .enum([
82
+ "SALES",
83
+ "SUBSCRIPTION",
84
+ "SUBSCRIPTION_EVENT",
85
+ "SUBSCRIBER",
86
+ "NEWSSTAND",
87
+ "PRE_ORDER",
88
+ ])
89
+ .optional()
90
+ .describe("Report type (default: SALES)"),
91
+ report_sub_type: z
92
+ .enum(["SUMMARY", "DETAILED", "OPT_IN"])
93
+ .optional()
94
+ .describe("Report sub type (default: SUMMARY)"),
95
+ vendor_number: z.string().optional().describe("Overrides ASC_VENDOR_NUMBER"),
96
+ version: z
97
+ .string()
98
+ .optional()
99
+ .describe("Report version, e.g. '1_0' or '1_1' (some report types require it)"),
100
+ }, async ({ report_date, frequency, report_type, report_sub_type, vendor_number, version }) => {
101
+ try {
102
+ const vendor = vendor_number ?? defaultVendorNumber;
103
+ if (!vendor) {
104
+ return err("No vendor number. Set ASC_VENDOR_NUMBER or pass vendor_number (App Store Connect > Payments and Financial Reports).");
105
+ }
106
+ const params = {
107
+ "filter[frequency]": frequency ?? "DAILY",
108
+ "filter[reportType]": report_type ?? "SALES",
109
+ "filter[reportSubType]": report_sub_type ?? "SUMMARY",
110
+ "filter[vendorNumber]": vendor,
111
+ "filter[reportDate]": report_date,
112
+ };
113
+ if (version)
114
+ params["filter[version]"] = version;
115
+ return ok(await client.getGzippedReport("/salesReports", params));
116
+ }
117
+ catch (e) {
118
+ return err(e);
119
+ }
120
+ });
121
+ server.tool("download_finance_report", "Download a Financial report as CSV text (decompressed from gzip). Requires a vendor number and a region code.", {
122
+ report_date: z.string().describe("Fiscal report date in 'YYYY-MM' format"),
123
+ region_code: z.string().describe("Region code, e.g. 'ZZ' for all regions, 'US', 'EU'"),
124
+ report_type: z
125
+ .enum(["FINANCIAL", "FINANCE_DETAIL"])
126
+ .optional()
127
+ .describe("Report type (default: FINANCIAL)"),
128
+ vendor_number: z.string().optional().describe("Overrides ASC_VENDOR_NUMBER"),
129
+ }, async ({ report_date, region_code, report_type, vendor_number }) => {
130
+ try {
131
+ const vendor = vendor_number ?? defaultVendorNumber;
132
+ if (!vendor) {
133
+ return err("No vendor number. Set ASC_VENDOR_NUMBER or pass vendor_number.");
134
+ }
135
+ const params = {
136
+ "filter[regionCode]": region_code,
137
+ "filter[reportDate]": report_date,
138
+ "filter[reportType]": report_type ?? "FINANCIAL",
139
+ "filter[vendorNumber]": vendor,
140
+ };
141
+ return ok(await client.getGzippedReport("/financeReports", params));
142
+ }
143
+ catch (e) {
144
+ return err(e);
145
+ }
146
+ });
147
+ }
@@ -0,0 +1,106 @@
1
+ import { z } from "zod";
2
+ import { ok, err, shapeResource, flattenResource } from "../asc/format.js";
3
+ const APP_FIELDS = "name,bundleId,sku,primaryLocale";
4
+ const BUILD_FIELDS = "version,uploadedDate,expirationDate,expired,minOsVersion,processingState";
5
+ export function registerAppTools(server, client) {
6
+ server.tool("list_apps", "List the apps in your App Store Connect account (id, name, bundleId, sku, primaryLocale). Use the returned id as app_id for the other tools.", {
7
+ bundle_id: z.string().optional().describe("Filter by exact bundle ID, e.g. 'com.acme.app'"),
8
+ limit: z
9
+ .number()
10
+ .int()
11
+ .min(1)
12
+ .max(200)
13
+ .optional()
14
+ .describe("Max apps to return (default: 100)"),
15
+ }, async ({ bundle_id, limit }) => {
16
+ try {
17
+ const params = { "fields[apps]": APP_FIELDS, limit: limit ?? 100 };
18
+ if (bundle_id)
19
+ params["filter[bundleId]"] = bundle_id;
20
+ const { data } = await client.getAll("/apps", params);
21
+ return ok({ count: data.length, apps: data.map((d) => flattenResource(d)) });
22
+ }
23
+ catch (e) {
24
+ return err(e);
25
+ }
26
+ });
27
+ server.tool("get_app", "Get a single app by ID (name, bundleId, sku, primaryLocale).", { app_id: z.string().describe("App Store Connect app ID") }, async ({ app_id }) => {
28
+ try {
29
+ const res = await client.get(`/apps/${encodeURIComponent(app_id)}`, {
30
+ "fields[apps]": APP_FIELDS,
31
+ });
32
+ return ok(flattenResource(res.data));
33
+ }
34
+ catch (e) {
35
+ return err(e);
36
+ }
37
+ });
38
+ server.tool("list_builds", "List TestFlight builds for an app (build/upload number, app version, platform, processing state, expiry). Filter by pre-release version or processing state.", {
39
+ app_id: z.string().describe("App Store Connect app ID"),
40
+ version: z.string().optional().describe("Filter by pre-release version, e.g. '1.2.0'"),
41
+ processing_state: z
42
+ .enum(["PROCESSING", "FAILED", "INVALID", "VALID"])
43
+ .optional()
44
+ .describe("Filter by processing state"),
45
+ limit: z.number().int().min(1).max(200).optional().describe("Max builds (default: 25)"),
46
+ }, async ({ app_id, version, processing_state, limit }) => {
47
+ try {
48
+ const params = {
49
+ "filter[app]": app_id,
50
+ include: "preReleaseVersion",
51
+ "fields[builds]": BUILD_FIELDS,
52
+ "fields[preReleaseVersions]": "version,platform",
53
+ sort: "-uploadedDate",
54
+ limit: limit ?? 25,
55
+ };
56
+ if (version)
57
+ params["filter[preReleaseVersion.version]"] = version;
58
+ if (processing_state)
59
+ params["filter[processingState]"] = processing_state;
60
+ const { data, included } = await client.getAll("/builds", params);
61
+ const builds = data.map((d) => shapeResource(d, included, { relationships: ["preReleaseVersion"] }));
62
+ return ok({ count: builds.length, builds });
63
+ }
64
+ catch (e) {
65
+ return err(e);
66
+ }
67
+ });
68
+ server.tool("get_build", "Get a single TestFlight build by ID, including its pre-release version.", { build_id: z.string().describe("Build ID") }, async ({ build_id }) => {
69
+ try {
70
+ const res = await client.get(`/builds/${encodeURIComponent(build_id)}`, {
71
+ include: "preReleaseVersion",
72
+ "fields[builds]": BUILD_FIELDS,
73
+ "fields[preReleaseVersions]": "version,platform",
74
+ });
75
+ return ok(shapeResource(res.data, res.included ?? [], {
76
+ relationships: ["preReleaseVersion"],
77
+ }));
78
+ }
79
+ catch (e) {
80
+ return err(e);
81
+ }
82
+ });
83
+ server.tool("list_customer_reviews", "List public App Store customer reviews for a released app (rating, title, body, reviewer, territory). Distinct from TestFlight beta feedback. Filter by rating or territory.", {
84
+ app_id: z.string().describe("App Store Connect app ID"),
85
+ rating: z.number().int().min(1).max(5).optional().describe("Filter by star rating (1-5)"),
86
+ territory: z.string().optional().describe("Filter by territory code, e.g. 'USA'"),
87
+ limit: z.number().int().min(1).max(200).optional().describe("Max reviews (default: 50)"),
88
+ }, async ({ app_id, rating, territory, limit }) => {
89
+ try {
90
+ const params = {
91
+ "fields[customerReviews]": "rating,title,body,reviewerNickname,createdDate,territory",
92
+ sort: "-createdDate",
93
+ limit: limit ?? 50,
94
+ };
95
+ if (rating !== undefined)
96
+ params["filter[rating]"] = rating;
97
+ if (territory)
98
+ params["filter[territory]"] = territory;
99
+ const { data } = await client.getAll(`/apps/${encodeURIComponent(app_id)}/customerReviews`, params);
100
+ return ok({ count: data.length, reviews: data.map((d) => flattenResource(d)) });
101
+ }
102
+ catch (e) {
103
+ return err(e);
104
+ }
105
+ });
106
+ }
@@ -0,0 +1,143 @@
1
+ import { z } from "zod";
2
+ import { ok, err, imageResult, shapeResource } from "../asc/format.js";
3
+ const PLATFORM = z.enum(["IOS", "MAC_OS", "TV_OS", "VISION_OS"]);
4
+ const TESTER_FIELDS = "firstName,lastName,email";
5
+ const BUILD_FIELDS = "version,uploadedDate";
6
+ const listShape = {
7
+ app_id: z.string().describe("App Store Connect app ID (from list_apps)"),
8
+ build_id: z.string().optional().describe("Filter to a single build ID"),
9
+ device_platform: PLATFORM.optional().describe("Filter by device platform"),
10
+ app_platform: PLATFORM.optional().describe("Filter by app platform"),
11
+ device_model: z.string().optional().describe("Filter by device model, e.g. 'iPhone15,2'"),
12
+ os_version: z.string().optional().describe("Filter by OS version string"),
13
+ tester_id: z.string().optional().describe("Filter by beta tester ID"),
14
+ sort: z
15
+ .enum(["createdDate", "-createdDate"])
16
+ .optional()
17
+ .describe("Sort order (default: -createdDate, newest first)"),
18
+ limit: z.number().int().min(1).max(200).optional().describe("Max items to return (default: 50)"),
19
+ };
20
+ function buildListParams(args, fieldsKey, fields) {
21
+ const params = {
22
+ include: "build,tester",
23
+ "fields[builds]": BUILD_FIELDS,
24
+ "fields[betaTesters]": TESTER_FIELDS,
25
+ [fieldsKey]: fields,
26
+ sort: args.sort ?? "-createdDate",
27
+ limit: args.limit ?? 50,
28
+ };
29
+ if (args.build_id)
30
+ params["filter[build]"] = args.build_id;
31
+ if (args.device_platform)
32
+ params["filter[devicePlatform]"] = args.device_platform;
33
+ if (args.app_platform)
34
+ params["filter[appPlatform]"] = args.app_platform;
35
+ if (args.device_model)
36
+ params["filter[deviceModel]"] = args.device_model;
37
+ if (args.os_version)
38
+ params["filter[osVersion]"] = args.os_version;
39
+ if (args.tester_id)
40
+ params["filter[tester]"] = args.tester_id;
41
+ return params;
42
+ }
43
+ const SCREENSHOT_FIELDS = "createdDate,comment,email,deviceModel,osVersion,locale,timeZone,architecture,connectionType,pairedAppleWatch,appUptimeInMilliseconds,diskBytesAvailable,diskBytesTotal,batteryPercentage,screenWidthInPoints,screenHeightInPoints,appPlatform,devicePlatform,deviceFamily,buildBundleId,screenshots";
44
+ const CRASH_FIELDS = "createdDate,comment,email,deviceModel,osVersion,locale,timeZone,architecture,connectionType,pairedAppleWatch,appUptimeInMilliseconds,diskBytesAvailable,diskBytesTotal,batteryPercentage,screenWidthInPoints,screenHeightInPoints,appPlatform,devicePlatform,deviceFamily,buildBundleId,crashLog";
45
+ function findCrashLogUrl(attrs) {
46
+ const cl = attrs?.crashLog;
47
+ if (typeof cl === "string")
48
+ return cl;
49
+ if (cl && typeof cl === "object" && typeof cl.url === "string") {
50
+ return cl.url;
51
+ }
52
+ return null;
53
+ }
54
+ export function registerFeedbackTools(server, client) {
55
+ server.tool("list_screenshot_feedback", "List TestFlight screenshot feedback submissions for an app. Each submission includes the tester's comment (the actual feedback text), the screenshot asset URL(s), device/OS details, and the resolved tester and build. Filter by build, platform, device, OS or tester.", listShape, async (args) => {
56
+ try {
57
+ const params = buildListParams(args, "fields[betaFeedbackScreenshotSubmissions]", SCREENSHOT_FIELDS);
58
+ const { data, included } = await client.getAll(`/apps/${encodeURIComponent(args.app_id)}/betaFeedbackScreenshotSubmissions`, params);
59
+ const items = data.map((d) => shapeResource(d, included, { relationships: ["build", "tester"] }));
60
+ return ok({ count: items.length, feedback: items });
61
+ }
62
+ catch (e) {
63
+ return err(e);
64
+ }
65
+ });
66
+ server.tool("list_crash_feedback", "List TestFlight crash feedback submissions for an app. Each submission includes any tester comment, device/OS details, the resolved tester and build, and a reference to the downloadable crash log (use get_crash_log). Filter by build, platform, device, OS or tester.", listShape, async (args) => {
67
+ try {
68
+ const params = buildListParams(args, "fields[betaFeedbackCrashSubmissions]", CRASH_FIELDS);
69
+ const { data, included } = await client.getAll(`/apps/${encodeURIComponent(args.app_id)}/betaFeedbackCrashSubmissions`, params);
70
+ const items = data.map((d) => shapeResource(d, included, { relationships: ["build", "tester"] }));
71
+ return ok({ count: items.length, feedback: items });
72
+ }
73
+ catch (e) {
74
+ return err(e);
75
+ }
76
+ });
77
+ server.tool("get_screenshot_feedback", "Get a single screenshot feedback submission by ID, including the tester comment, full device metadata, resolved tester and build, and screenshot asset URLs. Set download_screenshot to also return the first screenshot inline as an image.", {
78
+ feedback_id: z.string().describe("Screenshot feedback submission ID"),
79
+ download_screenshot: z
80
+ .boolean()
81
+ .optional()
82
+ .describe("Download and return the first screenshot inline as an image (default: false)"),
83
+ }, async ({ feedback_id, download_screenshot }) => {
84
+ try {
85
+ const res = await client.get(`/betaFeedbackScreenshotSubmissions/${encodeURIComponent(feedback_id)}`, {
86
+ include: "build,tester",
87
+ "fields[builds]": BUILD_FIELDS,
88
+ "fields[betaTesters]": TESTER_FIELDS,
89
+ "fields[betaFeedbackScreenshotSubmissions]": SCREENSHOT_FIELDS,
90
+ });
91
+ const resource = res.data;
92
+ const shaped = shapeResource(resource, res.included ?? [], {
93
+ relationships: ["build", "tester"],
94
+ });
95
+ if (download_screenshot) {
96
+ const shots = resource.attributes?.screenshots;
97
+ const url = shots?.[0]?.url;
98
+ if (url) {
99
+ const { base64, mimeType } = await client.downloadBinary(url);
100
+ const comment = resource.attributes?.comment || "(no comment)";
101
+ return imageResult(base64, mimeType, `Screenshot feedback ${feedback_id}: ${comment}`);
102
+ }
103
+ }
104
+ return ok(shaped);
105
+ }
106
+ catch (e) {
107
+ return err(e);
108
+ }
109
+ });
110
+ server.tool("get_crash_feedback", "Get a single crash feedback submission by ID, including any tester comment, full device metadata, resolved tester and build, and the crash log reference. Use get_crash_log to download the crash log text.", { feedback_id: z.string().describe("Crash feedback submission ID") }, async ({ feedback_id }) => {
111
+ try {
112
+ const res = await client.get(`/betaFeedbackCrashSubmissions/${encodeURIComponent(feedback_id)}`, {
113
+ include: "build,tester",
114
+ "fields[builds]": BUILD_FIELDS,
115
+ "fields[betaTesters]": TESTER_FIELDS,
116
+ "fields[betaFeedbackCrashSubmissions]": CRASH_FIELDS,
117
+ });
118
+ return ok(shapeResource(res.data, res.included ?? [], {
119
+ relationships: ["build", "tester"],
120
+ }));
121
+ }
122
+ catch (e) {
123
+ return err(e);
124
+ }
125
+ });
126
+ server.tool("get_crash_log", "Download the crash log text for a crash feedback submission. Resolves the temporary crash-log URL from the submission and fetches its contents.", { feedback_id: z.string().describe("Crash feedback submission ID") }, async ({ feedback_id }) => {
127
+ try {
128
+ const res = await client.get(`/betaFeedbackCrashSubmissions/${encodeURIComponent(feedback_id)}`, { "fields[betaFeedbackCrashSubmissions]": "crashLog" });
129
+ const attrs = res.data.attributes;
130
+ const url = findCrashLogUrl(attrs);
131
+ if (!url) {
132
+ return ok({
133
+ message: "No downloadable crash log URL is present on this submission.",
134
+ attributes: attrs ?? {},
135
+ });
136
+ }
137
+ return ok(await client.downloadText(url));
138
+ }
139
+ catch (e) {
140
+ return err(e);
141
+ }
142
+ });
143
+ }
@@ -0,0 +1,70 @@
1
+ import { z } from "zod";
2
+ import { ok, err, flattenResource } from "../asc/format.js";
3
+ const VERSION_FIELDS = "versionString,platform,appStoreState,releaseType,copyright,earliestReleaseDate,createdDate";
4
+ const LOCALIZATION_FIELDS = "locale,description,keywords,whatsNew,promotionalText,marketingUrl,supportUrl";
5
+ export function registerMetadataTools(server, client) {
6
+ server.tool("list_app_store_versions", "List App Store versions for an app (version string, platform, App Store state, release type). Filter by platform, version string or App Store state.", {
7
+ app_id: z.string().describe("App Store Connect app ID"),
8
+ platform: z
9
+ .enum(["IOS", "MAC_OS", "TV_OS", "VISION_OS"])
10
+ .optional()
11
+ .describe("Filter by platform"),
12
+ version: z.string().optional().describe("Filter by version string, e.g. '1.2.0'"),
13
+ app_store_state: z
14
+ .string()
15
+ .optional()
16
+ .describe("Filter by App Store state, e.g. 'READY_FOR_SALE'"),
17
+ limit: z.number().int().min(1).max(200).optional().describe("Max versions (default: 100)"),
18
+ }, async ({ app_id, platform, version, app_store_state, limit }) => {
19
+ try {
20
+ const params = {
21
+ "filter[app]": app_id,
22
+ "fields[appStoreVersions]": VERSION_FIELDS,
23
+ limit: limit ?? 100,
24
+ };
25
+ if (platform)
26
+ params["filter[platform]"] = platform;
27
+ if (version)
28
+ params["filter[versionString]"] = version;
29
+ if (app_store_state)
30
+ params["filter[appStoreState]"] = app_store_state;
31
+ const { data } = await client.getAll("/appStoreVersions", params);
32
+ return ok({ count: data.length, versions: data.map((d) => flattenResource(d)) });
33
+ }
34
+ catch (e) {
35
+ return err(e);
36
+ }
37
+ });
38
+ server.tool("list_app_store_version_localizations", "List the per-locale localizations for an App Store version (description, keywords, what's new, promotional text, URLs).", {
39
+ version_id: z.string().describe("App Store version ID (from list_app_store_versions)"),
40
+ limit: z
41
+ .number()
42
+ .int()
43
+ .min(1)
44
+ .max(200)
45
+ .optional()
46
+ .describe("Max localizations (default: 100)"),
47
+ }, async ({ version_id, limit }) => {
48
+ try {
49
+ const params = {
50
+ "filter[appStoreVersion]": version_id,
51
+ "fields[appStoreVersionLocalizations]": LOCALIZATION_FIELDS,
52
+ limit: limit ?? 100,
53
+ };
54
+ const { data } = await client.getAll("/appStoreVersionLocalizations", params);
55
+ return ok({ count: data.length, localizations: data.map((d) => flattenResource(d)) });
56
+ }
57
+ catch (e) {
58
+ return err(e);
59
+ }
60
+ });
61
+ server.tool("get_app_store_version_localization", "Get a single App Store version localization by ID (description, keywords, what's new, promotional text, URLs).", { localization_id: z.string().describe("App Store version localization ID") }, async ({ localization_id }) => {
62
+ try {
63
+ const res = await client.get(`/appStoreVersionLocalizations/${encodeURIComponent(localization_id)}`, { "fields[appStoreVersionLocalizations]": LOCALIZATION_FIELDS });
64
+ return ok(flattenResource(res.data));
65
+ }
66
+ catch (e) {
67
+ return err(e);
68
+ }
69
+ });
70
+ }
@@ -0,0 +1,100 @@
1
+ import { z } from "zod";
2
+ import { ok, err, flattenResource, shapeResource } from "../asc/format.js";
3
+ export function registerProvisioningTools(server, client) {
4
+ server.tool("list_devices", "List registered devices (name, platform, UDID, class, model, status). Filter by platform or status.", {
5
+ platform: z.enum(["IOS", "MAC_OS"]).optional().describe("Filter by device platform"),
6
+ status: z.enum(["ENABLED", "DISABLED"]).optional().describe("Filter by device status"),
7
+ limit: z.number().int().min(1).max(200).optional().describe("Max devices (default: 100)"),
8
+ }, async ({ platform, status, limit }) => {
9
+ try {
10
+ const params = {
11
+ "fields[devices]": "name,platform,udid,deviceClass,status,model,addedDate",
12
+ limit: limit ?? 100,
13
+ };
14
+ if (platform)
15
+ params["filter[platform]"] = platform;
16
+ if (status)
17
+ params["filter[status]"] = status;
18
+ const { data } = await client.getAll("/devices", params);
19
+ return ok({ count: data.length, devices: data.map((d) => flattenResource(d)) });
20
+ }
21
+ catch (e) {
22
+ return err(e);
23
+ }
24
+ });
25
+ server.tool("list_certificates", "List signing certificates (type, name, platform, serial number, expiry). Filter by certificate type.", {
26
+ certificate_type: z
27
+ .string()
28
+ .optional()
29
+ .describe("Filter by type, e.g. 'IOS_DISTRIBUTION', 'IOS_DEVELOPMENT', 'DISTRIBUTION'"),
30
+ limit: z
31
+ .number()
32
+ .int()
33
+ .min(1)
34
+ .max(200)
35
+ .optional()
36
+ .describe("Max certificates (default: 100)"),
37
+ }, async ({ certificate_type, limit }) => {
38
+ try {
39
+ const params = {
40
+ "fields[certificates]": "certificateType,displayName,name,platform,serialNumber,expirationDate",
41
+ limit: limit ?? 100,
42
+ };
43
+ if (certificate_type)
44
+ params["filter[certificateType]"] = certificate_type;
45
+ const { data } = await client.getAll("/certificates", params);
46
+ return ok({ count: data.length, certificates: data.map((d) => flattenResource(d)) });
47
+ }
48
+ catch (e) {
49
+ return err(e);
50
+ }
51
+ });
52
+ server.tool("list_profiles", "List provisioning profiles (name, platform, type, state, UUID, expiry) with their bundle ID. Filter by profile state.", {
53
+ profile_state: z.enum(["ACTIVE", "INVALID"]).optional().describe("Filter by profile state"),
54
+ limit: z.number().int().min(1).max(200).optional().describe("Max profiles (default: 100)"),
55
+ }, async ({ profile_state, limit }) => {
56
+ try {
57
+ const params = {
58
+ "fields[profiles]": "name,platform,profileType,profileState,uuid,createdDate,expirationDate",
59
+ "fields[bundleIds]": "identifier,name,platform",
60
+ include: "bundleId",
61
+ limit: limit ?? 100,
62
+ };
63
+ if (profile_state)
64
+ params["filter[profileState]"] = profile_state;
65
+ const { data, included } = await client.getAll("/profiles", params);
66
+ const profiles = data.map((d) => shapeResource(d, included, { relationships: ["bundleId"] }));
67
+ return ok({ count: profiles.length, profiles });
68
+ }
69
+ catch (e) {
70
+ return err(e);
71
+ }
72
+ });
73
+ server.tool("list_bundle_ids", "List registered bundle IDs (identifier, name, platform, seed ID). Filter by identifier or platform.", {
74
+ identifier: z
75
+ .string()
76
+ .optional()
77
+ .describe("Filter by bundle identifier, e.g. 'com.acme.app'"),
78
+ platform: z
79
+ .enum(["IOS", "MAC_OS", "UNIVERSAL"])
80
+ .optional()
81
+ .describe("Filter by bundle platform"),
82
+ limit: z.number().int().min(1).max(200).optional().describe("Max bundle IDs (default: 100)"),
83
+ }, async ({ identifier, platform, limit }) => {
84
+ try {
85
+ const params = {
86
+ "fields[bundleIds]": "identifier,name,platform,seedId",
87
+ limit: limit ?? 100,
88
+ };
89
+ if (identifier)
90
+ params["filter[identifier]"] = identifier;
91
+ if (platform)
92
+ params["filter[platform]"] = platform;
93
+ const { data } = await client.getAll("/bundleIds", params);
94
+ return ok({ count: data.length, bundleIds: data.map((d) => flattenResource(d)) });
95
+ }
96
+ catch (e) {
97
+ return err(e);
98
+ }
99
+ });
100
+ }
@@ -0,0 +1,64 @@
1
+ import { z } from "zod";
2
+ import { ok, err, flattenResource } from "../asc/format.js";
3
+ const GROUP_FIELDS = "name,isInternalGroup,publicLinkEnabled,publicLinkLimit,publicLink,feedbackEnabled,createdDate";
4
+ const TESTER_FIELDS = "firstName,lastName,email,inviteType,state";
5
+ export function registerTesterTools(server, client) {
6
+ server.tool("list_beta_groups", "List the TestFlight beta groups for an app (name, internal/external, public link, whether feedback is enabled).", {
7
+ app_id: z.string().describe("App Store Connect app ID"),
8
+ limit: z.number().int().min(1).max(200).optional().describe("Max groups (default: 100)"),
9
+ }, async ({ app_id, limit }) => {
10
+ try {
11
+ const params = {
12
+ "filter[app]": app_id,
13
+ "fields[betaGroups]": GROUP_FIELDS,
14
+ limit: limit ?? 100,
15
+ };
16
+ const { data } = await client.getAll("/betaGroups", params);
17
+ return ok({ count: data.length, groups: data.map((d) => flattenResource(d)) });
18
+ }
19
+ catch (e) {
20
+ return err(e);
21
+ }
22
+ });
23
+ server.tool("list_beta_testers", "List TestFlight beta testers (name, email, invite type, state). Filter by app, beta group and/or email.", {
24
+ app_id: z.string().optional().describe("Filter testers by app ID"),
25
+ group_id: z.string().optional().describe("Filter testers by beta group ID"),
26
+ email: z.string().optional().describe("Filter by exact tester email"),
27
+ limit: z.number().int().min(1).max(200).optional().describe("Max testers (default: 100)"),
28
+ }, async ({ app_id, group_id, email, limit }) => {
29
+ try {
30
+ const params = {
31
+ "fields[betaTesters]": TESTER_FIELDS,
32
+ sort: "lastName",
33
+ limit: limit ?? 100,
34
+ };
35
+ if (app_id)
36
+ params["filter[apps]"] = app_id;
37
+ if (group_id)
38
+ params["filter[betaGroups]"] = group_id;
39
+ if (email)
40
+ params["filter[email]"] = email;
41
+ const { data } = await client.getAll("/betaTesters", params);
42
+ return ok({ count: data.length, testers: data.map((d) => flattenResource(d)) });
43
+ }
44
+ catch (e) {
45
+ return err(e);
46
+ }
47
+ });
48
+ server.tool("list_group_testers", "List the beta testers that belong to a specific beta group.", {
49
+ group_id: z.string().describe("Beta group ID"),
50
+ limit: z.number().int().min(1).max(200).optional().describe("Max testers (default: 100)"),
51
+ }, async ({ group_id, limit }) => {
52
+ try {
53
+ const params = {
54
+ "fields[betaTesters]": TESTER_FIELDS,
55
+ limit: limit ?? 100,
56
+ };
57
+ const { data } = await client.getAll(`/betaGroups/${encodeURIComponent(group_id)}/betaTesters`, params);
58
+ return ok({ count: data.length, testers: data.map((d) => flattenResource(d)) });
59
+ }
60
+ catch (e) {
61
+ return err(e);
62
+ }
63
+ });
64
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@orellbuehler/testflight-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for TestFlight & App Store Connect: retrieve beta tester feedback (screenshots, crashes, crash logs), builds, testers, analytics and provisioning over the official App Store Connect API.",
5
+ "type": "module",
6
+ "bin": {
7
+ "testflight-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "!dist/__tests__"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.build.json",
15
+ "start": "node dist/index.js",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "typecheck": "tsc --noEmit",
19
+ "lint": "eslint src",
20
+ "format": "prettier --write .",
21
+ "format:check": "prettier --check .",
22
+ "prepare": "npm run build"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "keywords": [
28
+ "mcp",
29
+ "model-context-protocol",
30
+ "testflight",
31
+ "app-store-connect",
32
+ "apple",
33
+ "beta-testing",
34
+ "feedback",
35
+ "llm",
36
+ "ai"
37
+ ],
38
+ "author": "Orell Bühler",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/OrellBuehler/testflight-mcp.git"
43
+ },
44
+ "homepage": "https://github.com/OrellBuehler/testflight-mcp#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/OrellBuehler/testflight-mcp/issues"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.12.1",
53
+ "jose": "^6.0.11",
54
+ "zod": "^3.24.4"
55
+ },
56
+ "devDependencies": {
57
+ "@eslint/js": "^10.0.1",
58
+ "@types/node": "^22.15.3",
59
+ "eslint": "^10.4.1",
60
+ "eslint-config-prettier": "^10.1.8",
61
+ "prettier": "^3.8.3",
62
+ "typescript": "^5.8.3",
63
+ "typescript-eslint": "^8.60.0",
64
+ "vitest": "^4.1.0"
65
+ }
66
+ }