MCP Apple Ads
by mgcrea
README.md
# @mgcrea/mcp-apple-ads
Model Context Protocol server for the **Apple Ads Platform API** — ad accounts, campaigns, ad groups, keywords, ads and reporting.
Writes are off by default, everything is created paused, and optional ceilings cap what any tool may spend.
## Features
- **34 tools** across accounts, apps, campaigns, ad groups, keywords, ads, shared budgets and reporting — plus a raw-request escape hatch for the rest of the API.
- **Reporting that fits in a context window.** Totals by default, zero-valued metrics dropped, metadata trimmed to the fields that identify a row, and per-period rows opt-in and capped. A 90-day × 200-keyword report shapes down by more than 10×.
- **Money safety in four layers** — see [Spending safety](#spending-safety).
- **No SDK dependency.** Two runtime dependencies (`@modelcontextprotocol/server`, `zod`); the ES256 client secret is signed with `node:crypto`.
## API version and the 2027 sunset
This server targets the **Apple Ads Platform API 1.0** (`https://api.ads.apple.com/v1/`), which Apple shipped in August 2026.
It does **not** target the older Campaign Management API v5 (`api.searchads.apple.com/api/v5`), which Apple sunsets on **26 January 2027**. There is no version switch: a second shaping path for an API that will be gone is not worth the code, and anyone still on v5 has to migrate regardless.
## Security
**Your credentials.** The private key is read once at startup and never leaves the process. It is never logged, never returned by a tool, and `apple_ads_auth_status` reports only whether it loaded. Prefer `APPLE_ADS_PRIVATE_KEY` (inline PEM, from a secret store) over a path on disk. If you do use a file, `chmod 600` it — the server warns when it is group-readable.
Unlike App Store Connect, nothing here is downloadable-once: you generate the key pair and register only the public half, so a lost key is regenerable.
**Blast radius.** This server can spend money. That is why:
- writes are unregistered, not merely refused, unless `APPLE_ADS_ALLOW_WRITES=1`;
- every write — including creates — requires `confirm: true`;
- everything is created `PAUSED` unless `activate: true`;
- `APPLE_ADS_MAX_DAILY_BUDGET` and `APPLE_ADS_MAX_BID` are checked locally before any request goes out.
## Configure
### Getting the key
Apple Ads credentials are **not** App Store Connect credentials, and **not** Apple Business Manager credentials. All three consoles issue similar-looking EC keys and none of them interoperate.
```bash
mkdir -p ~/.config/apple-ads && chmod 700 ~/.config/apple-ads
cd ~/.config/apple-ads
openssl ecparam -genkey -name prime256v1 -noout -out apple-ads-private.pem
openssl ec -in apple-ads-private.pem -pubout -out apple-ads-public.pem
chmod 600 apple-ads-private.pem
```
Then, at **ads.apple.com**:
1. **Account Settings → User Management** — invite a user with an API role (`API Account Manager` for writes, `API Account Read Only` for reads). Inviting yourself is fine.
2. **Sign in as that API user.**
3. **Account Settings → API** — paste the contents of `apple-ads-public.pem`. Apple then prints `clientId`, `teamId` and `keyId` above the field.
> **The trap that costs everyone an afternoon:** an **Account Admin has no Public Key field.** That role sees a "Manage API Access" table instead, and the API page looks like it has nothing to create. You must invite an API-role user and sign in _as that user_ before the field appears. Apple's own help page describes step 1 and step 3 as though they happen to the same person, which is why this is easy to miss.
>
> A related wrong turn: **Apple Business Manager** (`business.apple.com`) has an "Add API Account" dialog that looks almost identical and hands you a downloadable-once private key. It is a different service. Its `clientId` starts `BUSINESSAPI.`; an Apple Ads one starts `SEARCHADS.`.
The API user's **access level is the real permission boundary** — `APPLE_ADS_ALLOW_WRITES` is a second gate on top, not a substitute for picking the right role.
### Environment
| Variable | Required | Notes |
| ---------------------------- | -------- | ------------------------------------------------------------------------------------ |
| `APPLE_ADS_CLIENT_ID` | yes | `SEARCHADS.<uuid>` |
| `APPLE_ADS_TEAM_ID` | yes | `SEARCHADS.<uuid>`, usually identical to the client id — expected, not a paste error |
| `APPLE_ADS_KEY_ID` | yes | UUID of the registered public key |
| `APPLE_ADS_PRIVATE_KEY` | one of | EC P-256 private key, inline PEM |
| `APPLE_ADS_PRIVATE_KEY_PATH` | one of | path to the `.pem` |
| `APPLE_ADS_AD_ACCOUNT_ID` | no | default ad account; required when the login reaches several |
| `APPLE_ADS_ALLOW_WRITES` | no | registers the mutating tools |
| `APPLE_ADS_MAX_DAILY_BUDGET` | no | ceiling in major units on any daily budget a tool may set |
| `APPLE_ADS_MAX_BID` | no | ceiling in major units on any bid |
| `APPLE_ADS_CONFIG` | no | config file path (default `~/.config/apple-ads/config.json`) |
| `APPLE_ADS_MAX_RETRIES` | no | default 3 |
| `APPLE_ADS_DEBUG` | no | stderr debug logging |
Environment wins over the config file **per field**, so a one-off `APPLE_ADS_ALLOW_WRITES=0` overrides a file that says `true`.
## Quick start
```jsonc
// .mcp.json
{
"mcpServers": {
"apple-ads": {
"command": "npx",
"args": ["-y", "@mgcrea/mcp-apple-ads"],
"env": {
"APPLE_ADS_CLIENT_ID": "SEARCHADS.…",
"APPLE_ADS_TEAM_ID": "SEARCHADS.…",
"APPLE_ADS_KEY_ID": "…",
"APPLE_ADS_PRIVATE_KEY_PATH": "/Users/you/.config/apple-ads/apple-ads-private.pem",
},
},
},
}
```
### Inspect the tools
```bash
npx @modelcontextprotocol/inspector npx -y @mgcrea/mcp-apple-ads
```
## Tools
| Tool | What it does | Writes? |
| ---------------------------------------------------------------------------------- | --------------------------------------------------------- | ------- |
| `apple_ads_auth_status` | Credentials, reachable ad accounts, ceilings, setup steps | |
| `apple_ads_list_accounts` | Ad accounts this key can reach — the bootstrap call | |
| `apple_ads_search_apps` | Find an app's adamId | |
| `apple_ads_get_app` | One app's advertising details | |
| `apple_ads_list_campaigns` / `apple_ads_get_campaign` | Campaign configuration | |
| `apple_ads_list_ad_groups` / `apple_ads_get_ad_group` | Ad group configuration | |
| `apple_ads_list_keywords` | Targeting keywords with bids | |
| `apple_ads_list_negative_keywords` | Excluded terms | |
| `apple_ads_suggest_keywords` | Apple's keyword suggestions | |
| `apple_ads_list_ads` / `apple_ads_list_creatives` / `apple_ads_list_product_pages` | Creative surface | |
| `apple_ads_list_budgets` | Shared budgets | |
| `apple_ads_get_report` | **The performance numbers**, at five levels | |
| `apple_ads_get_impression_share` | Headroom per search term | |
| `apple_ads_get_search_term_popularity` | Search demand per term | |
| `apple_ads_request` | Any endpoint no tool wraps | gated |
| `apple_ads_create_campaign` / `update` / `delete` | Campaign lifecycle | ✓ |
| `apple_ads_create_ad_group` / `update` / `delete` | Ad group lifecycle | ✓ |
| `apple_ads_create_keywords` / `update` / `delete` | Keyword management, bulk | ✓ |
| `apple_ads_create_negative_keywords` / `delete` | Exclusions | ✓ |
| `apple_ads_create_ad` / `delete` | Ads | ✓ |
| `apple_ads_set_entity_status` | **Pause or resume** any of the four | ✓ |
| `apple_ads_update_budget` | Change a shared budget | ✓ |
Listing cost, measured: **26 KB / ~6.5k tokens** with writes off, **49 KB / ~12k tokens** with writes on.
### Deliberately not wrapped
Reachable through `apple_ads_request`: Maps advertising (`/business-brands/*` and its five report variants), asset upload and creative authoring, recommendations apply/dismiss, ad-account creation, location targeting, change history.
## Worked example
> Which keywords lost me money last month, and pause them.
1. `apple_ads_list_accounts` → the ad account id
2. `apple_ads_get_report` at `level: "keyword"`, last 30 days, no granularity → totals per keyword
3. read `localSpend` against `totalInstalls`
4. `apple_ads_set_entity_status` with `entity: "keyword"`, `status: "PAUSED"` on each loser
Pausing, not deleting: a paused keyword stops spending and keeps its history.
## Spending safety
| Layer | What it does |
| --------------- | --------------------------------------------------------------------------------------------- |
| Write gate | Without `APPLE_ADS_ALLOW_WRITES=1` the 15 write tools are not in `tools/list` at all |
| `confirm: true` | Required on **every** write, creates included — a campaign created ENABLED spends immediately |
| Created PAUSED | `activate: false` is the default on every create |
| Ceilings | `APPLE_ADS_MAX_DAILY_BUDGET` / `APPLE_ADS_MAX_BID` refuse locally, before any request is sent |
The ceilings do **not** apply to `apple_ads_request`, which bypasses them by construction. Its description says so.
## Apple Ads vs App Store Connect
Two APIs, two consoles, two keys — which is why they are two servers rather than one. They share exactly one thing: **`adamId` is the same integer as an app's `id` in App Store Connect**, and as the digits in its App Store URL.
If you run [`@mgcrea/mcp-appstore-connect`](https://github.com/mgcrea/mcp-appstore-connect) too, `app_store_connect_list_apps` is the cheaper way to get an adamId than `apple_ads_search_apps`.
## Traps worth knowing
- **A 403 is usually the ad-account context, not your credentials.** If the token minted but calls fail, the `X-Ap-Context` header is missing or names an account this login has no role on. `apple_ads_auth_status` lists what is reachable.
- **`/acls` and `/me` are the only calls that take no ad-account context** — necessarily, since they are how you discover which accounts exist.
- **Money amounts are strings upstream.** `Money.amount` is `"50.00"`, not `50`. This server converts both ways; if you use `apple_ads_request` you are on your own.
- **Keyword deletion is permanent and takes the reporting history with it.** Pausing is nearly always what was meant.
- **A multi-account login must name an account.** Tools refuse rather than guess, because guessing spends money in someone else's account.
- **Report granularity has windows.** HOURLY needs a start within 7 days (and is unavailable for search-term and ad reports), DAILY within 90, WEEKLY within 365 with an end 14+ days old, MONTHLY with an end 90+ days old. These are checked locally, so you get the rule rather than `INVALID_PARAMETER`.
- **Search-term reports reject `timeZone: "UTC"`.** Use `ORTZ`, which is the default here.
## Troubleshooting
| Symptom | Cause |
| ------------------------------------ | -------------------------------------------------------------------------------------------- |
| Tool missing from the list | Writes disabled, or no credentials — run `apple_ads_auth_status` |
| `HTTP 401` on every call | Client/team/key id mismatch, or the private key is not the pair of the registered public key |
| `HTTP 403` with good credentials | Ad-account context — see Traps |
| "This login can reach N ad accounts" | Pass `adAccountId`, or set `APPLE_ADS_AD_ACCOUNT_ID` |
| Budget refused with no request sent | A ceiling is set — this is working as intended |
## Develop
```bash
pnpm install
pnpm build
pnpm test
pnpm typecheck && pnpm lint
```
Tests are fully offline: `fetch` is mocked and the MCP client is in-memory. No credentials are needed or read.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues