Skip to content

Commit e7978d2

Browse files
v1.8.0: build & ship tools (bump_build_number, archive_app, upload_build)
Local Xcode tooling so the agent can bump the build number (agvtool), archive + export a signed .ipa (xcodebuild), and upload to App Store Connect (altool) using the same API key. Friendly install guidance when Xcode/CLI tools are missing.
1 parent 2bce3e1 commit e7978d2

5 files changed

Lines changed: 242 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to this project are documented here. The format follows
44
[Keep a Changelog](https://keepachangelog.com/) and the project uses
55
[Semantic Versioning](https://semver.org/).
66

7+
## [1.8.0] - 2026-06-02
8+
9+
### Added
10+
- **Build & ship from a Mac** (local Xcode tooling):
11+
- `bump_build_number` — increment/set the build number via agvtool.
12+
- `archive_app` — archive + export a signed `.ipa` via xcodebuild.
13+
- `upload_build` — upload to App Store Connect via `xcrun altool`, reusing the
14+
same API key; auto-places the `.p8` where altool expects it.
15+
- Each tool returns friendly install guidance if Xcode/CLI tools are missing.
16+
717
## [1.7.2] - 2026-06-02
818

919
### Changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ Full parameter reference: **[docs/TOOLS.md](docs/TOOLS.md)**.
253253
| `list_game_center_leaderboards` / `list_game_center_achievements` | 🎮 Game Center leaderboards & achievements |
254254
| `signing_health` | 🔐 Flag **certificates & profiles expiring soon** (or invalid) across the account — catches CI breakage early |
255255
| `list_bundle_ids` / `register_bundle_id` / `list_devices` / `register_device` / `list_certificates` / `create_certificate` / `revoke_certificate` / `list_profiles` / `create_profile` / `download_profile` / `delete_profile` | 🔏 **Provisioning & code signing** — bundle IDs, devices, certificates, and provisioning profiles |
256+
| `bump_build_number` / `archive_app` / `upload_build` | 🛠️ **Build & ship from a Mac** — bump the build number (agvtool), archive + export a signed `.ipa` (xcodebuild), and upload to App Store Connect (`altool`, using your existing API key). Requires Xcode; tools explain how to install it if missing |
256257
| `raw_request` | 🧰 Any method/path against the API — app previews, matchmaking, Xcode Cloud, anything not above |
257258

258259
> ⚙️ **Rate-limit aware:** the client automatically backs off and retries on

docs/TOOLS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,30 @@ Lists certificates and provisioning profiles, flagging expired/expiring (within
389389
### list_game_center_leaderboards / list_game_center_achievements
390390
- `appId` **(required)** — requires Game Center enabled on the app.
391391

392+
## Build & ship (macOS + Xcode)
393+
394+
These run local Xcode tooling, so they only work on a Mac with Xcode installed.
395+
Each returns clear install guidance if a tool is missing.
396+
397+
### bump_build_number
398+
- `projectDir` **(required)** — folder containing the `.xcodeproj`
399+
- `setTo` — exact build number (omit to increment by 1). Uses `agvtool`.
400+
401+
### archive_app
402+
Archive + export a signed `.ipa` for App Store upload (`xcodebuild`). **Can take
403+
several minutes** — your MCP client may need a longer tool timeout.
404+
- `project` **or** `workspace` **(one required)** — absolute path
405+
- `scheme` **(required)**, `configuration` (default Release)
406+
- `exportMethod` (default `app-store-connect`), `teamId`, `outputDir`
407+
- **Returns:** `{ ipaPath, archivePath, exportPath }`
408+
409+
### upload_build
410+
Upload an `.ipa` via `xcrun altool --upload-app`, using your App Store Connect
411+
API key (`ASC_KEY_ID` / `ASC_ISSUER_ID`). The `.p8` is auto-placed where altool
412+
looks (`~/.appstoreconnect/private_keys/`). After processing, the build appears
413+
in `list_builds` and can be submitted with `submit_for_review`.
414+
- `ipaPath` **(required)**, `platform` (default `ios`), `apiKey`, `apiIssuer`
415+
392416
---
393417

394418
## Rate limits

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "appstore-api-mcp",
3-
"version": "1.7.2",
3+
"version": "1.8.0",
44
"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).",
55
"type": "module",
66
"bin": {

src/index.js

Lines changed: 206 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
#!/usr/bin/env node
2-
import { readFileSync } from "node:fs";
3-
import { basename } from "node:path";
2+
import {
3+
readFileSync,
4+
writeFileSync,
5+
mkdirSync,
6+
existsSync,
7+
copyFileSync,
8+
readdirSync,
9+
} from "node:fs";
10+
import { basename, join } from "node:path";
11+
import { homedir, tmpdir } from "node:os";
12+
import { execFile } from "node:child_process";
413
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
514
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
615
import {
@@ -131,6 +140,61 @@ const EDITABLE_VERSION_STATES = new Set([
131140
// Optional default Vendor Number for sales/finance reports.
132141
const DEFAULT_VENDOR = process.env.ASC_VENDOR_NUMBER;
133142

143+
// ---- Local build tooling (archive & upload) helpers ----
144+
145+
/** Run a command, capturing output. timeout 0 = no timeout (for long archives). */
146+
function runCmd(cmd, args, opts = {}) {
147+
return new Promise((resolve) => {
148+
execFile(
149+
cmd,
150+
args,
151+
{
152+
cwd: opts.cwd,
153+
timeout: opts.timeout || 0,
154+
maxBuffer: 64 * 1024 * 1024,
155+
env: process.env,
156+
},
157+
(err, stdout, stderr) => {
158+
resolve({
159+
code: err && typeof err.code === "number" ? err.code : err ? 1 : 0,
160+
stdout: stdout || "",
161+
stderr: stderr || "",
162+
error: err ? err.message : null,
163+
});
164+
},
165+
);
166+
});
167+
}
168+
169+
const tail = (s, n = 40) => (s || "").split("\n").slice(-n).join("\n");
170+
171+
/** Throw a friendly install-guidance error if Xcode CLI tools aren't available. */
172+
async function ensureXcode() {
173+
if (process.platform !== "darwin")
174+
throw new Error(
175+
"Archiving/uploading requires macOS with Xcode. These build tools only run on a Mac.",
176+
);
177+
const sel = await runCmd("xcode-select", ["-p"]);
178+
if (sel.code !== 0)
179+
throw new Error(
180+
"Xcode command-line tools not found. To fix: 1) install Xcode from the Mac App Store, 2) run `xcode-select --install` (or, if Xcode is already installed, `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`), then verify with `xcodebuild -version`.",
181+
);
182+
return sel.stdout.trim();
183+
}
184+
185+
/** Make sure altool can find the .p8: copy it to ~/.appstoreconnect/private_keys/. */
186+
function ensureAltoolKey(keyId) {
187+
const src = process.env.ASC_PRIVATE_KEY_PATH;
188+
if (!src || !existsSync(src)) return false;
189+
const dir = join(homedir(), ".appstoreconnect", "private_keys");
190+
const dest = join(dir, `AuthKey_${keyId}.p8`);
191+
if (!existsSync(dest)) {
192+
mkdirSync(dir, { recursive: true });
193+
copyFileSync(src, dest);
194+
}
195+
return true;
196+
}
197+
134198
/** Cap parsed report rows so large reports don't flood the response. */
135199
function reportResult(reportType, parsed, limit = 200) {
136200
const rows = parsed.rows;
@@ -2001,6 +2065,145 @@ const tools = [
20012065
},
20022066
},
20032067

2068+
// ---- Local build: archive & upload (macOS + Xcode) ----
2069+
{
2070+
name: "bump_build_number",
2071+
description:
2072+
"Increment (or set) an Xcode project's build number (CFBundleVersion / CURRENT_PROJECT_VERSION) via agvtool. macOS + Xcode required. Pass projectDir = the folder containing the .xcodeproj.",
2073+
inputSchema: {
2074+
type: "object",
2075+
properties: {
2076+
projectDir: { type: "string", description: "Folder containing the .xcodeproj" },
2077+
setTo: { type: "string", description: "Set to this exact build number; omit to increment by 1" },
2078+
},
2079+
required: ["projectDir"],
2080+
},
2081+
run: async (a) => {
2082+
await ensureXcode();
2083+
const cur = await runCmd("xcrun", ["agvtool", "what-version", "-terse"], { cwd: a.projectDir });
2084+
const previous = (cur.stdout || "").trim();
2085+
const res = a.setTo
2086+
? await runCmd("xcrun", ["agvtool", "new-version", "-all", a.setTo], { cwd: a.projectDir })
2087+
: await runCmd("xcrun", ["agvtool", "next-version", "-all"], { cwd: a.projectDir });
2088+
if (res.code !== 0)
2089+
return {
2090+
error:
2091+
"agvtool failed — ensure the project's Versioning System is 'Apple Generic' (target → Build Settings → Versioning), or set the build number in Xcode manually.",
2092+
detail: tail(res.stderr || res.stdout, 8),
2093+
previous,
2094+
};
2095+
const after = await runCmd("xcrun", ["agvtool", "what-version", "-terse"], { cwd: a.projectDir });
2096+
return { previous, current: (after.stdout || "").trim() };
2097+
},
2098+
},
2099+
{
2100+
name: "archive_app",
2101+
description:
2102+
"Archive an Xcode app and export a signed .ipa ready for App Store upload (xcodebuild archive + -exportArchive). macOS + Xcode required. Returns the .ipa path. CAN TAKE SEVERAL MINUTES — your MCP client may need a longer tool timeout; xcodebuild keeps running server-side regardless.",
2103+
inputSchema: {
2104+
type: "object",
2105+
properties: {
2106+
project: { type: "string", description: "Absolute path to the .xcodeproj" },
2107+
workspace: { type: "string", description: "Absolute path to the .xcworkspace (use instead of project)" },
2108+
scheme: { type: "string" },
2109+
configuration: { type: "string", description: "Release (default)" },
2110+
exportMethod: { type: "string", description: "app-store-connect (default), release-testing, enterprise, …" },
2111+
teamId: { type: "string", description: "Signing team id (optional)" },
2112+
outputDir: { type: "string", description: "Where to write the archive + ipa (default: a temp dir)" },
2113+
},
2114+
required: ["scheme"],
2115+
},
2116+
run: async (a) => {
2117+
await ensureXcode();
2118+
if (!a.project && !a.workspace)
2119+
return { error: "Provide either project (.xcodeproj) or workspace (.xcworkspace)." };
2120+
const safe = a.scheme.replace(/\W+/g, "_");
2121+
const out = a.outputDir || join(tmpdir(), `asc-archive-${safe}`);
2122+
mkdirSync(out, { recursive: true });
2123+
const archivePath = join(out, `${safe}.xcarchive`);
2124+
const exportPath = join(out, "export");
2125+
const target = a.workspace
2126+
? ["-workspace", a.workspace]
2127+
: ["-project", a.project];
2128+
const archiveArgs = [
2129+
...target,
2130+
"-scheme", a.scheme,
2131+
"-configuration", a.configuration || "Release",
2132+
"-destination", "generic/platform=iOS",
2133+
"-archivePath", archivePath,
2134+
"archive",
2135+
"-allowProvisioningUpdates",
2136+
];
2137+
const arch = await runCmd("xcodebuild", archiveArgs);
2138+
if (arch.code !== 0)
2139+
return { step: "archive", error: "xcodebuild archive failed", log: tail(arch.stdout + "\n" + arch.stderr, 50) };
2140+
const plistPath = join(out, "ExportOptions.plist");
2141+
const method = a.exportMethod || "app-store-connect";
2142+
writeFileSync(
2143+
plistPath,
2144+
`<?xml version="1.0" encoding="UTF-8"?>
2145+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2146+
<plist version="1.0"><dict>
2147+
<key>method</key><string>${method}</string>
2148+
<key>signingStyle</key><string>automatic</string>
2149+
${a.teamId ? `<key>teamID</key><string>${a.teamId}</string>\n` : ""}<key>uploadSymbols</key><true/>
2150+
</dict></plist>
2151+
`,
2152+
);
2153+
const exp = await runCmd("xcodebuild", [
2154+
"-exportArchive",
2155+
"-archivePath", archivePath,
2156+
"-exportOptionsPlist", plistPath,
2157+
"-exportPath", exportPath,
2158+
"-allowProvisioningUpdates",
2159+
]);
2160+
if (exp.code !== 0)
2161+
return { step: "export", error: "xcodebuild -exportArchive failed", log: tail(exp.stdout + "\n" + exp.stderr, 50) };
2162+
const ipa = existsSync(exportPath)
2163+
? readdirSync(exportPath).find((f) => f.endsWith(".ipa"))
2164+
: null;
2165+
if (!ipa)
2166+
return { error: "No .ipa was produced.", exportPath, files: existsSync(exportPath) ? readdirSync(exportPath) : [] };
2167+
return { ipaPath: join(exportPath, ipa), archivePath, exportPath };
2168+
},
2169+
},
2170+
{
2171+
name: "upload_build",
2172+
description:
2173+
"Upload an .ipa to App Store Connect via `xcrun altool --upload-app`, using your App Store Connect API key (the same ASC_KEY_ID / ASC_ISSUER_ID this server already uses). macOS + Xcode required. After it finishes processing (minutes), the build appears in list_builds and can be submitted with submit_for_review.",
2174+
inputSchema: {
2175+
type: "object",
2176+
properties: {
2177+
ipaPath: { type: "string" },
2178+
platform: { type: "string", description: "ios (default), macos, tvos" },
2179+
apiKey: { type: "string", description: "Override ASC_KEY_ID" },
2180+
apiIssuer: { type: "string", description: "Override ASC_ISSUER_ID" },
2181+
},
2182+
required: ["ipaPath"],
2183+
},
2184+
run: async (a) => {
2185+
await ensureXcode();
2186+
if (!existsSync(a.ipaPath))
2187+
return { error: `File not found: ${a.ipaPath}` };
2188+
const keyId = a.apiKey || process.env.ASC_KEY_ID;
2189+
const issuer = a.apiIssuer || process.env.ASC_ISSUER_ID;
2190+
if (!keyId || !issuer)
2191+
return { error: "apiKey + apiIssuer required (or set ASC_KEY_ID / ASC_ISSUER_ID)." };
2192+
ensureAltoolKey(keyId);
2193+
const res = await runCmd("xcrun", [
2194+
"altool",
2195+
"--upload-app",
2196+
"-t", a.platform || "ios",
2197+
"-f", a.ipaPath,
2198+
"--apiKey", keyId,
2199+
"--apiIssuer", issuer,
2200+
]);
2201+
if (res.code !== 0)
2202+
return { error: "Upload failed", log: tail(res.stdout + "\n" + res.stderr, 40) };
2203+
return { uploaded: true, output: tail(res.stdout + "\n" + res.stderr, 20) };
2204+
},
2205+
},
2206+
20042207
// ---- Generic escape hatch ----
20052208
{
20062209
name: "raw_request",
@@ -2044,7 +2247,7 @@ Some things a great App Store listing needs live OUTSIDE App Store Connect and c
20442247
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.`;
20452248

20462249
const server = new Server(
2047-
{ name: "appstore-api-mcp", version: "1.7.2" },
2250+
{ name: "appstore-api-mcp", version: "1.8.0" },
20482251
{ capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS },
20492252
);
20502253

0 commit comments

Comments
 (0)