appstore-connect-mcp
# appstore-connect-mcp
A local-only [Model Context Protocol](https://modelcontextprotocol.io) server that gives Claude
Code (or any MCP-capable coding agent) safe access to **App Store Connect only**: apps, builds,
TestFlight, App Store versions, metadata, App Info, screenshots, review information, age rating,
availability, pricing, content rights, and review submission — enough to take a build from
"already uploaded" to "submitted for review" with a single human confirmation at the end.
This project deliberately does **not** touch Xcode. Xcode operations (build, test, archive,
source editing, SwiftUI previews) are handled by Apple's own official Xcode MCP. Claude Code
orchestrates both servers together. See [CLAUDE.md](CLAUDE.md) for the full architecture and
security rules.
```
Claude Code
├── xcode → Apple's official Xcode MCP (xcrun mcpbridge)
└── appstore-connect-mcp → this project (stdio)
```
- macOS only
- stdio transport only — no HTTP server, no open ports, no external deploy target
- No database
- Your `.p8` private key is never written into this repo or committed. It lives outside the
repo (e.g. `~/.config/appstore-connect-mcp/`) and is read from disk only when signing a JWT.
## Requirements
- macOS
- Xcode 26.3+ (for the Xcode MCP side of the workflow)
- Node.js (managed via mise)
- pnpm (managed via mise)
- [mise](https://mise.jdx.dev/)
- Claude Code
- An App Store Connect **Team API Key** (Key ID + Issuer ID + downloaded `.p8`)
## Setup
### 1. Xcode MCP (Apple's official server — not part of this repo)
```bash
claude mcp add --transport stdio xcode -- xcrun mcpbridge
```
In Xcode: **Settings → Intelligence → Model Context Protocol → Allow external agents to use
Xcode tools**.
Verify:
```bash
claude mcp list
```
### 2. appstore-connect-mcp
```bash
mise install
mise run install # pnpm install
```
### 3. Credentials
Create an App Store Connect **Team API Key** at Users and Access → Integrations → Team Keys, note
its **Key ID** and **Issuer ID**, and download the `.p8` file once (Apple only allows downloading
it once).
Store the `.p8` outside this repository:
```bash
mkdir -p ~/.config/appstore-connect-mcp
chmod 700 ~/.config/appstore-connect-mcp
mv ~/Downloads/AuthKey_XXXXXXXXXX.p8 ~/.config/appstore-connect-mcp/
chmod 600 ~/.config/appstore-connect-mcp/AuthKey_XXXXXXXXXX.p8
```
(The key's own contents are never printed anywhere by this project — including here.)
Then set three environment variables, either by copying `.env.example` to `.env` in this repo
and filling it in, or by exporting them in your shell / setting them directly in your MCP client
config (see below):
```
ASC_KEY_ID=<your Key ID>
ASC_ISSUER_ID=<your Issuer ID>
ASC_PRIVATE_KEY_PATH=~/.config/appstore-connect-mcp/AuthKey_XXXXXXXXXX.p8
```
`.env`, `.p8`, and anything matching `credentials.*`/`secrets.*` are gitignored — see
[.gitignore](.gitignore).
### 4. Verify
```bash
mise run test # unit tests — no credentials needed
mise run typecheck
mise run build
```
To confirm your credentials actually authenticate, start the server (`mise run dev`) with the
env vars set and call `asc_apps_list` from Claude Code — a successful response confirms the JWT
and credentials are valid end-to-end.
## Registering with Claude Code
Add appstore-connect-mcp as its own MCP server, alongside `xcode`:
```bash
claude mcp add appstore-connect-mcp -- mise run dev --cwd /absolute/path/to/appstore-connect-mcp
```
Replace `/absolute/path/to/appstore-connect-mcp` with wherever you cloned this repo — that path is
environment-specific and shouldn't be assumed.
If your Claude Code client doesn't support `claude mcp add` (e.g. a desktop app without the CLI),
add an entry to its MCP config JSON instead:
```json
{
"mcpServers": {
"appstore-connect-mcp": {
"command": "mise",
"args": ["-C", "/absolute/path/to/appstore-connect-mcp", "run", "dev"],
"env": {
"ASC_KEY_ID": "...",
"ASC_ISSUER_ID": "...",
"ASC_PRIVATE_KEY_PATH": "/Users/you/.config/appstore-connect-mcp/AuthKey_XXXXXXXXXX.p8"
}
}
}
}
```
Use `mise -C <dir> run dev` (rather than a bare `cwd` field) if your client doesn't apply `cwd`
to the spawned process — `-C` is mise's own "change directory before running" flag and doesn't
depend on the client supporting a `cwd` key.
After registering, confirm both servers are visible:
```bash
claude mcp list
```
You should see both `xcode` and `appstore-connect-mcp`.
## Tools
App identification never requires the numeric Apple app id — pass a bundle id
(`io.tmkch.colorvia`) or app name (`Colorvia`) and it's resolved for you.
Tool titles are tagged `[Read]`, `[Write]`, or `[Destructive/Release]` so risk level is visible
at a glance (see `CLAUDE.md` for the philosophy behind this).
| Tool | Class | Description |
| --- | --- | --- |
| `asc_apps_list` | Read | List all apps on the account |
| `asc_app_get` | Read | Get one app's details |
| `asc_builds_list` | Read | List an app's uploaded builds |
| `asc_build_get` | Read | Get a single build |
| `asc_build_status` | Read | Get a build's processing state |
| `asc_build_upload` | Write | Upload a build (`.ipa`/`.pkg`) to App Store Connect |
| `asc_build_upload_status` | Read | Get the state of an in-progress build upload |
| `asc_beta_groups_list` | Read | List TestFlight beta groups |
| `asc_beta_group_add_build` | Write | Add a build to a TestFlight group |
| `asc_beta_build_localization_get` | Read | Get "What to Test" notes |
| `asc_beta_build_localization_update` | Write | Update "What to Test" notes |
| `asc_beta_build_status` | Read | Get TestFlight-specific build status |
| `asc_versions_list` | Read | List an app's App Store versions |
| `asc_version_get` | Read | Get a single App Store version |
| `asc_version_create` | Write | Create a new App Store version (supports `dryRun`) |
| `asc_version_update` | Write | Update a version's fields (supports `dryRun`) |
| `asc_version_select_build` | Write | Attach a build to a version (supports `dryRun`) |
| `asc_metadata_get` | Read | Get localized App Store metadata for a version |
| `asc_metadata_update` | Write | Update localized metadata (supports `dryRun`) |
| `asc_metadata_create_locale` | Write | Add a new metadata locale to a version (supports `dryRun`) |
| `asc_version_ensure` | Write | Return the version if it exists, else create it — race-safe (supports `dryRun`) |
| `asc_build_latest` | Read | Find the highest uploaded build number and suggest the next one |
| `asc_build_wait` | Read | Poll a build/build-upload until it's terminal (SUCCESS/FAILED/TIMEOUT/CANCELLED) |
| `asc_screenshot_sets_list` | Read | List screenshot sets (and their screenshots) for a version/locale |
| `asc_screenshot_upload` | Write | Upload a screenshot, creating its set if needed |
| `asc_screenshot_delete` | **Destructive/Release** | Permanently delete a screenshot |
| `asc_review_get` | Read | Get review contact/demo-account/notes — **never returns the password** |
| `asc_review_update` | Write | Update review contact/demo-account/notes (password write-only, supports `dryRun`) |
| `asc_age_rating_get` | Read | Get the age rating declaration |
| `asc_age_rating_update` | Write | Update the age rating declaration (supports `dryRun`) |
| `asc_app_info_localizations_list` | Read | List app-level localizations (name, subtitle, privacy policy) |
| `asc_app_info_localization_get` | Read | Get one app-level localization |
| `asc_app_info_localization_create` | Write | Add a new App Info locale (supports `dryRun`) |
| `asc_app_info_localization_update` | Write | Update an App Info locale (supports `dryRun`) |
| `asc_availability_get` | Read | Get which territories the app is available in |
| `asc_availability_update` | Write | Replace the full territory list, or `["ALL"]` (supports `dryRun`) |
| `asc_pricing_get` | Read | Get whether pricing is configured and whether it's free |
| `asc_pricing_set_free` | Write | Set the app to free (supports `dryRun`) |
| `asc_content_rights_get` | Read | Get the content rights declaration |
| `asc_content_rights_update` | Write | Update the content rights declaration (supports `dryRun`) |
| `asc_iap_list` / `asc_iap_get` | Read | List / get in-app purchases (consumable, non-consumable, non-renewing) |
| `asc_iap_create` | Write | Create an in-app purchase |
| `asc_iap_localizations_list` | Read | List an in-app purchase's per-locale name/description |
| `asc_iap_localization_create` / `_update` | Write | Add / update an in-app purchase locale |
| `asc_iap_price_points_list` | Read | List Apple's fixed price tiers for an in-app purchase in one territory |
| `asc_iap_pricing_get` | Read | Get an in-app purchase's pricing status |
| `asc_iap_price_update` | Write | Set an in-app purchase's price (auto-equalized across territories) |
| `asc_iap_review_screenshot_get` | Read | Get an in-app purchase's App Review-only screenshot |
| `asc_iap_review_screenshot_upload` | Write | Upload/replace an in-app purchase's App Review-only screenshot |
| `asc_subscription_groups_list` / `_get` | Read | List / get auto-renewable subscription groups |
| `asc_subscription_group_create` | Write | Create a subscription group |
| `asc_subscriptions_list` / `asc_subscription_get` | Read | List / get subscriptions within a group |
| `asc_subscription_create` | Write | Create an auto-renewable subscription in a group |
| `asc_subscription_group_localizations_list` | Read | List a subscription group's per-locale display name |
| `asc_subscription_group_localization_create` | Write | Add a subscription group locale |
| `asc_subscription_localizations_list` | Read | List a subscription's per-locale name/description |
| `asc_subscription_localization_create` / `_update` | Write | Add / update a subscription locale |
| `asc_subscription_price_points_list` | Read | List Apple's fixed price tiers for a subscription in one territory |
| `asc_subscription_price_update` | Write | Set a subscription's price in one territory (**not** auto-equalized — call once per territory) |
| `asc_subscription_review_screenshot_get` | Read | Get a subscription's App Review-only screenshot |
| `asc_subscription_review_screenshot_upload` | Write | Upload/replace a subscription's App Review-only screenshot |
| `asc_release_sync` | Write | Sync `.appstore/` SSOT config to App Store Connect, diff-based (supports `dryRun`) |
| `asc_release_preflight` | Read | Comprehensive App Review readiness check |
| `asc_submission_status` | Read | Get App Review status for a version |
| `asc_submit_for_review` | **Destructive/Release** | Submit a version for App Review — runs preflight first, **always shows a native macOS confirmation dialog** |
| `asc_review_submission_cancel` | Write | Withdraw the app's currently open review submission |
There is intentionally no generic "call any App Store Connect endpoint" tool, and no tool that
deletes apps or in-app purchases.
### Not automatable through the public API
- **App Privacy Nutrition Labels** (data collection / tracking responses) have no documented
public App Store Connect API endpoint. `asc_release_preflight` always reports this check as
`manual_required` — set it manually in App Store Connect under App Privacy.
- **Paid price points** — `asc_pricing_set_free` reliably handles free apps; setting a specific
paid price is not exposed as a tool (Apple's price-schedule API has had several documented
behavior changes on their developer forums, and a half-correct paid-pricing tool is worse than
none — see `src/tools/pricing/service.ts`).
- **Export compliance** (`ITSAppUsesNonExemptEncryption`) is an Xcode/Info.plist-side setting;
`asc_release_preflight` reports it when detectable on the uploaded build, but doesn't set it.
- **Attaching a new (not-yet-reviewed) in-app purchase or subscription to a review submission**
has no public API — neither `ReviewSubmissionItem` nor `AppStoreVersion` exposes a relationship
to in-app purchases/subscriptions (verified against the API's schema). Select it manually in App
Store Connect on the version's page ("In-App Purchases and Subscriptions") before calling
`asc_submit_for_review` — otherwise Apple rejects the submission itself, typically with an HTTP
409 whose `errors` array names the unreviewed item (see `AppStoreConnectError.errors`, which
surfaces Apple's *entire* errors array, not just the first entry).
- Apple Developer Program agreement acceptance (including the **Paid Apps Agreement**, required
before `asc_pricing_set_free`/paid IAP or subscription pricing will work), tax/banking info, and
creating the app record for the very first time all still require the App Store Connect web UI —
there is no public API to check agreement status, so a 409/422 on a pricing/availability write
that persists after retrying is worth checking there first.
### `asc_build_upload` / `asc_screenshot_upload` implementation notes
Both are implemented against Apple's actual published App Store Connect API schema — not
guessed:
- **Builds** use the `BuildUpload` / `BuildUploadFile` resources (App Store Connect API 4.1+):
create the upload, create the file record, upload the file in the chunks specified by its
`uploadOperations`, then commit with an MD5 `sourceFileChecksums.file` checksum. Processing then
continues on Apple's servers — poll `asc_build_upload_status`, then `asc_builds_list`.
- **Screenshots** use the older `AppScreenshotSet` / `AppScreenshot` resources (API 1.2+), which
use the same chunked-upload pattern but commit with a plain `sourceFileChecksum` MD5 string
instead of a nested checksum object. `asc_screenshot_upload` creates the screenshot set
automatically if one doesn't already exist for the given locale/display type.
Both were verified field-by-field against `developer.apple.com/documentation/appstoreconnectapi`
before implementation (see the header comments in `src/tools/uploads/service.ts` and
`src/tools/screenshots/service.ts` for the exact resources referenced).
### SSOT config (`.appstore/`)
Instead of calling metadata/App-Info/availability/age-rating/review/pricing/content-rights tools
one at a time, an app repo can define a single source of truth that `asc_release_sync` reads and
reconciles against App Store Connect (diff-based — only fields that actually differ get written):
```
.appstore/
├── appstore.yml
├── en-US/
│ ├── name.txt
│ ├── subtitle.txt
│ ├── description.txt
│ ├── keywords.txt
│ ├── promotional_text.txt
│ └── whats_new.txt
└── ja/
└── ... (same files)
```
```yaml
app:
bundleId: io.tmkch.example
release:
type: MANUAL
pricing:
type: FREE
availability:
territories: [JPN, USA, CAN, GBR]
review:
signInRequired: false
usernameEnv: APP_REVIEW_USERNAME
passwordEnv: APP_REVIEW_PASSWORD
ageRating:
userGeneratedContent: false
unrestrictedWebAccess: false
gambling: false
```
**Secrets are referenced by environment variable name only** — `review.usernameEnv` /
`review.passwordEnv`, resolved from the environment at sync time. A literal `review.password` (or
`username`) key in the YAML is rejected by the schema outright. All file access is confined to
`<projectRoot>/.appstore/` (see `src/utils/projectPath.ts`) — directory traversal via `..` or an
unsafe locale/filename is rejected before any read happens.
Screenshots are not part of this format yet — upload them with `asc_screenshot_upload` directly.
App Privacy is always reported as `manual_required` (no public API — see above).
#### Global defaults (`~/.config/appstore-connect-mcp/defaults.yml`)
Values that are the same across every app you ship — copyright, support/privacy/marketing URL,
review contact, default release type — don't need to be repeated in every app's
`.appstore/appstore.yml`. Put them once in an optional, non-repo, user-level file (same directory
convention as the `.p8` key; override the path with `ASC_GLOBAL_DEFAULTS_PATH`):
```yaml
metadata:
copyright: "2026 Tomokichi"
supportUrl: "https://tmkch.io/support"
privacyPolicyUrl: "https://tmkch.io/privacy"
review:
contactFirstName: "Tomoki"
contactEmail: "support@tmkch.io"
release:
type: "AFTER_APPROVAL"
```
`asc_release_sync` layers this under each app's own `.appstore/appstore.yml` — an app-specific
value always wins, an unset one falls back to the global default. App-specific-only fields (App
Privacy, IAP, screenshots, age rating, pricing type, etc.) are never defaulted globally; see
CLAUDE.md's "Global と App固有設定を分離する" for why. Missing the file entirely is fine — this
layer is opt-in.
#### Version format
Marketing/App Store versions must be strict `Major.Minor.Patch` (`1.0.0`, not `1.0`, `v1.0.0`, or
`1.0.0-beta`) — enforced by `src/utils/version.ts`. `asc_version_create`, `asc_version_update`
(when changing `versionString`), and `asc_version_ensure` reject anything else outright; invalid
versions are never auto-corrected, since silently rewriting one hides a real mismatch between the
project, the build, and App Store Connect. `asc_release_preflight` additionally checks
`versionFormat` and `versionConsistency` (the selected build's marketing version must match the
App Store Connect version) before allowing submission.
### Release flow
```
Xcode MCP: Build → Test
↓
asc_build_latest → next build number
↓
Xcode MCP: set version/build number → Archive → Export
↓
asc_build_upload → asc_build_wait (poll until VALID)
↓
asc_version_ensure
↓
asc_release_sync (or individual metadata/appInfo/availability/ageRating/pricing/contentRights tools)
↓
asc_version_select_build
↓
asc_release_preflight → must be ready: true
↓
asc_submit_for_review → preflight re-checked → macOS confirmation dialog → human clicks Submit
```
See [CLAUDE.md](CLAUDE.md) for the full orchestration rules (what never to skip, e.g. never
submitting with failing tests or a still-processing build).
### `dryRun`
Write tools that support it accept `dryRun: true` and return the change as a before/after diff
instead of applying it:
```json
{ "appId": "Colorvia", "version": "1.1", "locale": "en-US", "description": "...", "dryRun": true }
```
```json
{ "dryRun": true, "action": "asc_metadata_update", "changes": [
{ "field": "description", "old": "...", "new": "..." }
] }
```
## Development
```bash
mise run dev # run the MCP server from source (stdio)
mise run build # compile to dist/
mise run typecheck
mise run test # unit tests — no App Store Connect credentials required
```
Unit tests never hit the real App Store Connect API — HTTP is stubbed at the `fetch` boundary,
and JWT tests sign against a throwaway EC key generated in the test itself.
## Project layout
```
src/
├── index.ts # MCP entrypoint (stdio)
├── server/mcp.ts # registers every tool group
├── auth/
│ ├── config.ts # env var loading + .p8 file reading
│ └── jwt.ts # ES256 Team API Key JWT signing/caching
├── asc/
│ ├── client.ts # asc.get/post/patch/delete — not exposed as an MCP tool
│ ├── errors.ts # AppStoreConnectError (HTTP/Reason/Suggestion), AscNetworkError
│ └── types.ts # shared Apple JSON:API + summary types
├── tools/
│ ├── apps/ builds/ uploads/ testflight/ versions/ metadata/ screenshots/ submissions/
│ ├── review/ ageRating/ appInfo/ availability/ pricing/ contentRights/ buildWait/
│ ├── iap/ subscriptions/
│ ├── releaseSync/ releasePreflight/
│ └── each: service.ts (App Store Connect logic) + index.ts (MCP tool registration)
├── config/
│ ├── ssotSchema.ts # zod schema for .appstore/appstore.yml
│ ├── ssotLoader.ts # reads/validates SSOT + locale files (path-confined)
│ ├── globalDefaultsSchema.ts # zod schema for ~/.config/appstore-connect-mcp/defaults.yml
│ ├── globalDefaultsLoader.ts # loads the optional user-level defaults file
│ └── mergeDefaults.ts # layers app SSOT config over global defaults (app always wins)
├── ci/buildProvider.ts # BuildProvider interface only — no implementation (see CLAUDE.md)
├── security/confirm.ts # osascript confirmation dialog (submit-for-review)
└── utils/
├── resolveApp.ts, resolveVersion.ts, resolveAppInfo.ts # id resolution + caching
├── mcpResult.ts # toolJson/toolError helpers
├── dryRun.ts # shared dryRun diff builder
├── fileUpload.ts # chunked uploadOperations PUT + MD5 checksum
├── version.ts # strict Major.Minor.Patch validation + incrementVersion
└── projectPath.ts # path-traversal guard for SSOT file access
tests/ # node:test unit tests, no live API calls
```
TDQS
Scored across 44 tools
Each tool targets a distinct resource and action, with clear descriptions that separate similar-sounding tools (e.g., build status vs upload status vs wait). A few pairs like app-info localization vs metadata could be confused without close reading, but overall the boundaries are well-defined.
Every tool follows the consistent pattern asc_<resource>_<action> (e.g., asc_builds_list, asc_version_update, asc_screenshot_upload), using snake_case throughout with no camelCase or irregular verbs. This makes the naming highly predictable and scannable even at scale.
At 44 tools, the server significantly exceeds the 25-tool threshold where coherence typically degrades. While the App Store Connect domain is broad, the granularity here is high and could overwhelm agents; some operations could likely be consolidated.
The tool set covers core release workflows comprehensively: app management, builds, versions, metadata, localization, beta groups, pricing, availability, review, screenshots, submission, and preflight. Minor gaps exist—no version deletion, no beta group creation/update, and no privacy management—but they are non-critical for most release automation.