google-ads-write-mcp
by YerayRodri
README.md
# google-ads-write-mcp
MCP server that exposes **write** operations on Google Ads: campaigns, ad
groups, keywords, RSA ads, sitelinks, images, Customer Match audiences and
recommendations.
**Complements the official `google-ads` MCP** (read-only). Use the official
one (or [google-ads-kw-mcp](../google-ads-kw-mcp)) to read data, this one to
write.
## Tools (26)
### Campaigns
| Tool | What it does |
|---|---|
| `update_campaign_status` | Pause, enable or remove a campaign |
| `update_campaign_budget` | Change the daily budget (locates the budget entity automatically) |
### Ad groups
| Tool | What it does |
|---|---|
| `update_ad_group_status` | Pause, enable or remove an ad group |
| `update_ad_group_cpc_bid` | Change the ad group's max CPC bid |
### Keywords
| Tool | What it does |
|---|---|
| `add_keywords` | Add keywords to an ad group (BROAD/PHRASE/EXACT, optional bid) |
| `update_keyword_status` | Pause, enable or remove a keyword |
| `update_keyword_bid` | Change a keyword's CPC bid |
| `add_negative_keywords` | Add negative keywords at campaign level |
### RSA ads
| Tool | What it does |
|---|---|
| `update_rsa_headlines` | Replace headlines (and optionally descriptions) of an RSA |
### Sitelinks
| Tool | What it does |
|---|---|
| `create_sitelink_asset` | Create a sitelink asset |
| `link_sitelink_to_campaign` | Link the sitelink to a campaign |
### Ad assets
| Tool | What it does |
|---|---|
| `create_callout_asset` | Create a callout text (max 25 chars) |
| `create_call_asset` | Create a call extension with a phone number |
| `create_structured_snippet_asset` | Create a structured snippet (header + list of values) |
| `link_asset_to_campaign` | Link any asset to a campaign (SITELINK, CALLOUT, CALL, STRUCTURED_SNIPPET, MARKETING_IMAGE…) |
### Images
| Tool | What it does |
|---|---|
| `upload_image_asset` | Download an image from a URL and upload it as an asset (Display/PMax) |
### Audiences / Customer Match
| Tool | What it does |
|---|---|
| `list_user_lists` | List all audiences (remarketing + Customer Match) |
| `create_customer_match_list` | Create a new Customer Match list |
| `upload_customer_match_emails` | Upload emails to a list (SHA-256 hashing done automatically) |
### Create full campaigns
| Tool | What it does |
|---|---|
| `create_campaign` | Create a Search or Performance Max campaign (budget, bid, country, language, EU political-ads flag) |
| `create_ad_group` | Create an ad group inside a Search campaign |
| `create_rsa` | Create a full RSA ad (3–15 headlines, 2–4 descriptions, URL, display paths) |
| `create_asset_group` | Create a PMax asset group (headlines, long headlines, descriptions, images, logo) |
### Recommendations
| Tool | What it does |
|---|---|
| `list_recommendations` | List pending recommendations (optional type filter) |
| `apply_recommendation` | Apply a recommendation |
| `dismiss_recommendation` | Dismiss a recommendation |
## Security
- Every tool ships with [MCP Tool Annotations](https://modelcontextprotocol.io/specification)
(`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), so MCP clients can use
them to decide whether to prompt for confirmation before running a tool.
- Execution errors propagate as real MCP protocol errors (`isError: true`), not as a JSON payload
that looks like a success with an `"error"` key buried inside — so the calling model actually
sees the failure and can self-correct instead of silently treating it as a success.
## Setup
Same credentials as [google-ads-kw-mcp](../google-ads-kw-mcp) — you can point
both at the same `credentials.json`.
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
## MCP client configuration
```json
{
"mcpServers": {
"google-ads-write": {
"command": "/path/to/.venv/bin/python3",
"args": ["/path/to/google-ads-write-mcp/server.py"],
"env": {
"GOOGLE_ADS_CREDENTIALS_PATH": "~/.config/google-ads-mcp/credentials.json",
"GOOGLE_ADS_DEVELOPER_TOKEN": "<your developer token>",
"GOOGLE_ADS_LOGIN_CUSTOMER_ID": "<your MCC customer id, no dashes>"
}
}
}
}
```
| Env var | Purpose |
|---|---|
| `GOOGLE_ADS_CREDENTIALS_PATH` | Path to the credentials JSON (default: `~/.config/google-ads-mcp/credentials.json`) |
| `GOOGLE_ADS_DEVELOPER_TOKEN` | Your Google Ads developer token |
| `GOOGLE_ADS_LOGIN_CUSTOMER_ID` | Your MCC/login customer ID, no dashes |
| `GOOGLE_ADS_API_VERSION` | Google Ads API version (default: `v25`, pinned in `server.py`). All ~80 mutate calls in this server inherit it from the client. It's deliberately fixed rather than left to the `google-ads` library's default, so a `pip install --upgrade google-ads` can't silently move every write operation to a newer API version. Only override after reading the [release notes](https://developers.google.com/google-ads/api/docs/release-notes) — must match [google-ads-kw-mcp](../google-ads-kw-mcp)'s version if you run both against the same account. |
## Confirmation required for critical operations
These tools require `confirmed=True`. Called without it, they return a JSON
with `requires_confirmation: true` that the calling agent MUST show to the
user and wait for a response before repeating the call with `confirmed=True`.
| Tool | When it asks for confirmation |
|---|---|
| `update_campaign_status` | status = PAUSED or REMOVED |
| `update_campaign_budget` | always |
| `update_ad_group_status` | status = REMOVED |
```
# 1. First call — no confirmed
update_campaign_status(customer_id="1234567890", campaign_id="111222333", status="PAUSED")
# → returns requires_confirmation: true → show it to the user
# 2. Second call — after the user confirms
update_campaign_status(customer_id="1234567890", campaign_id="111222333", status="PAUSED", confirmed=True)
```
## Gotchas found in production use
### Auth
- **Never use `gcloud auth print-access-token`** — that token doesn't carry
the `adwords` scope → 403. The only valid source is the `refresh_token`
against `oauth2.googleapis.com/token`, which this server already does
correctly.
### Negatives vs. positives
- Adding a negative EXACT keyword to an ad group where the same keyword
already exists as a positive EXACT match returns `IMMUTABLE_FIELD`. The fix
is to **pause the positive** instead of adding the negative.
### Resource names
- `adGroupCriteria` resource names always need a `~` between the ad group ID
and the criterion ID: `customers/{cid}/adGroupCriteria/{ag_id}~{crit_id}`.
### GAQL field limits
- `ad_group_criterion` doesn't accept metrics (clicks, cost, conversions) in
the same query — query `search_term_view` separately for performance data.
- Filter `ad_group_criterion` by campaign with
`WHERE ad_group.campaign = 'customers/{cid}/campaigns/{campaign_id}'`, not
`campaign_id` directly.
### Budgets
- The budget is a separate entity (`CampaignBudget`). If a campaign shares a
budget with others, changing it affects all of them.
### `contains_eu_political_advertising`
- This is an enum (`EuPoliticalAdvertisingStatus`), not a bool. `False`
serializes as `UNSPECIFIED`, which the API rejects as "not present". Use
`DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING` explicitly.
### PMax with Brand Guidelines
- If the account has Brand Guidelines enabled, PMax campaigns need a
`business_name` and a `logo` (min. 128×128px, square) linked as
`CampaignAsset` at creation time — not after.
### RSA headline updates
- Use `AdService.mutate_ads()` with field mask `"responsive_search_ad.headlines"`.
Using `AdGroupAdService` with `"ad.responsive_search_ad.headlines"` gives
`IMMUTABLE_FIELD`.
### Removing a keyword
- Use the `remove` operation on `AdGroupCriterionOperation`. Trying an
`update` with `status = REMOVED` gives `INVALID_ENUM_VALUE`.
### REMOVED resources are immutable
- No write operation works on ad groups, keywords or ads whose parent
campaign has `status = REMOVED`
(`OPERATION_NOT_PERMITTED_FOR_REMOVED_RESOURCE`).
### Customer Match allowlist
- `upload_customer_match_emails` fails with
`CUSTOMER_NOT_ALLOWLISTED_FOR_THIS_FEATURE` unless your developer token has
explicit Customer Match access. `create_customer_match_list` works without
it — only the email upload needs the allowlist.
## Notes
- Monetary amounts are passed in the account's currency units and converted
to micros internally (1 unit = 1,000,000 micros).
- `customer_id` is always passed without dashes.
- `update_rsa_headlines` replaces ALL current headlines — pass the full
desired list, not just the new ones.
- Customer Match lists can take up to 48h to process on Google's side.
## License
MIT — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues