|
1 | 1 | #!/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"; |
4 | 13 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; |
5 | 14 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
6 | 15 | import { |
@@ -131,6 +140,61 @@ const EDITABLE_VERSION_STATES = new Set([ |
131 | 140 | // Optional default Vendor Number for sales/finance reports. |
132 | 141 | const DEFAULT_VENDOR = process.env.ASC_VENDOR_NUMBER; |
133 | 142 |
|
| 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 | + |
134 | 198 | /** Cap parsed report rows so large reports don't flood the response. */ |
135 | 199 | function reportResult(reportType, parsed, limit = 200) { |
136 | 200 | const rows = parsed.rows; |
@@ -2001,6 +2065,145 @@ const tools = [ |
2001 | 2065 | }, |
2002 | 2066 | }, |
2003 | 2067 |
|
| 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 | + |
2004 | 2207 | // ---- Generic escape hatch ---- |
2005 | 2208 | { |
2006 | 2209 | name: "raw_request", |
@@ -2044,7 +2247,7 @@ Some things a great App Store listing needs live OUTSIDE App Store Connect and c |
2044 | 2247 | 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.`; |
2045 | 2248 |
|
2046 | 2249 | const server = new Server( |
2047 | | - { name: "appstore-api-mcp", version: "1.7.2" }, |
| 2250 | + { name: "appstore-api-mcp", version: "1.8.0" }, |
2048 | 2251 | { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }, |
2049 | 2252 | ); |
2050 | 2253 |
|
|
0 commit comments