Skip to main content
Glama
tomoki013

appstore-connect-mcp

by tomoki013
README.md
# 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

A3.8/5.0

Scored across 44 tools

Disambiguation4/5

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.

Naming Consistency5/5

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.

Tool Count2/5

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.

Completeness4/5

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.

Maintenance

ActivitySlowing
ResponsivenessNo issues