apple-ads-mcp
by Newticus
README.md
# apple-ads-mcp
Local MCP server for the [Apple Ads Platform API](https://developer.apple.com/documentation/apple-ads-platform-api)
(`https://api.ads.apple.com/v1`). This API replaces Search Ads API v5, which is reported to shut down on 2027-01-26.
It covers App Store and Apple Maps campaigns, reports, keywords, budgets and recommendations.
## Tools
| Tool | What it does |
| --- | --- |
| `apple_ads_search` | Keyword search over the offline docs: 99 endpoints, ~410 objects, ~160 enum types, guides |
| `apple_ads_doc` | One doc page as markdown: body schema, allowed values, constraints, example payloads |
| `apple_ads_accounts` | Checks credentials, lists visible ad accounts and roles (`/v1/me`, `/v1/acls`) |
| `apple_ads_read` | GET, plus POST to `/query` endpoints (lists, reports, insights, suggestions). Refuses anything else |
| `apple_ads_write` | POST/PUT/DELETE that change live campaigns. Not registered when `APPLE_ADS_READ_ONLY=1` |
`apple_ads_read` options: `allPages` follows pagination and merges pages (capped by `maxPages`, default 50). `outputFile` writes
the full JSON to an absolute path and returns only a summary. A response over 60k chars is saved to
`$TMPDIR/apple-ads-mcp/` automatically. Reason: Claude Code caps MCP output at 25k tokens by default, and the call should
still succeed when a report is big.
## Setup
1. **API user.** An Apple Ads account admin invites an Apple Account with an API role
(ads.apple.com > Account Settings > User Management > Invite Users). Accept the invite and sign in as that user.
2. **Key pair.** Run `npm run keygen`. It writes `~/.appleads/private-key.pem` (mode 600) and prints the public key.
3. **Upload.** Paste the public key into ads.apple.com > Account Settings > API. Apple then shows `clientId`, `teamId`
and `keyId`.
4. **Credentials.** Pick one:
- **1Password (preferred, same as bluesky-mcp).** Store the three IDs and the PEM *contents* in item
`apple-ads-api` in the **my-vault** vault (the only vault the launcher's service account can read), copy
`.env.op.example` to `.env.op`, fix the `op://` references, then delete the key file. `bin/apple-ads-mcp` sees
`.env.op` and starts the server under `op run`. Nothing secret ends up in `~/.claude.json`.
- **Key file.** Keep `~/.appleads/private-key.pem` and put the IDs in the registration:
`claude mcp add --scope user apple-ads -e APPLE_ADS_CLIENT_ID=... -e APPLE_ADS_TEAM_ID=... -e APPLE_ADS_KEY_ID=... -- ~/Projects/tools/apple-ads-mcp/bin/apple-ads-mcp`
5. **Verify.** Run `node scripts/probe.mjs`. The `accounts` section should list your ad accounts.
Optional env: `APPLE_ADS_AD_ACCOUNT_ID` pins the account for `X-AP-Context`. When unset, the server uses the only
visible account and errors out if there are several, because a guess could send writes to the wrong account.
`APPLE_ADS_PRIVATE_KEY_PATH` overrides the key file location.
## Design notes
- **Few generic tools and an offline catalog, not one tool per endpoint.** 99 tool schemas would cost context in every
session, ads or not. Search, doc and a raw call cover the same surface, and a new endpoint only needs a catalog rebuild.
Community v5 servers built the per-endpoint way ended up with 54 to 74 tools.
- **Read and write are separate tools** so a client can allow-list reads while writes still prompt. The API runs every
query as a POST, so the HTTP method alone can't decide; see `isReadOnly` in `lib/catalog.js`. `apple_ads_write` also
accepts paths missing from the catalog, because Apple can ship an endpoint before the catalog is rebuilt.
`apple_ads_read` never does.
- **The catalog comes from crawling the docs, since Apple publishes no OpenAPI spec.** Its generated clients
(`apple/apple-ads-platform-api-*`) are built from a spec that isn't in any of those repos (checked 2026-09-19). The
catalog is committed so the server starts offline. Rebuild it with `npm run build-catalog` when Apple bumps the API:
their clients track the version in `spec.version`, which was 109 on 2026-08-14.
- **OAuth is hand-rolled; Apple's `@apple/apple-ads-platform` isn't used.** This server doesn't need its typed methods.
The library also pulls in axios and friends, and its default logger writes to stdout, which is the MCP channel. The
whole flow is one ES256 signature plus one form POST (`lib/auth.js`). A fresh client secret is minted per token
request, so no long-lived secret exists besides the key itself.
- **Rate limits** follow Apple's guidance: pace on `RateLimit-Remaining`, honor `Retry-After` on 429, and double the
backoff up to 16 s (`lib/client.js`).
## Status
Built 2026-09-19, **before any live credentials existed**. The unit tests, the stdio probe and the docs tools are
verified. The token request was checked against Apple's real endpoint with a dummy key (Apple parsed it and answered
`invalid_client`). Nothing has run against a real ad account yet. The first real use may turn up response shapes the
pagination merge doesn't expect. `fetchAllPages` falls back to returning the raw page when that happens.
## Development
```bash
npm test # unit tests (node --test)
node scripts/probe.mjs # end-to-end over stdio
npm run build-catalog # re-crawl developer.apple.com into catalog.json
```
## First run: build the docs catalog
`catalog.json` (Apple's crawled documentation) is not in this repo, because the text is Apple's.
Run `npm install && npm run build-catalog` once; the server then searches it offline.
The launcher reads the 1Password service-account token from the macOS keychain item named by
`OP_TOKEN_KEYCHAIN_SERVICE` (default `op-service-account-token`), or from `OP_SERVICE_ACCOUNT_TOKEN` if set.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues