Skip to main content
Glama
minholi

Google Ads MCP Server

README.md
# Google Ads MCP Server

[MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that integrates with the **Google Ads API**. Lets AI agents (Claude, Cursor, etc.) query campaigns, metrics, audiences, and geography, and analyze budgets — all through natural language.

---

## Two operating modes

| Mode | Who uses it | Auth | Google refresh token |
|---|---|---|---|
| **stdio** | Just you (Claude Desktop / Cursor local) | None — local subprocess | Single, in `.env` (generated by `scripts/get_refresh_token.py`) |
| **HTTP multi-tenant** | Team / remote access | Native MCP OAuth 2.1 — each user authenticates with their own Google account | One per user, encrypted in local SQLite |

Pick **stdio** for personal use (simpler). Pick **HTTP** when multiple users (with different Google accounts) need to share the same server.

---

## Available tools

| Tool | Description |
|---|---|
| `google_ads_list_accounts` | Lists accessible accounts (useful under an MCC) |
| `google_ads_list_campaigns` | Campaigns with KPIs (cost, clicks, CTR, ROAS, CPA) |
| `google_ads_list_ad_groups` | Ad groups with performance grouped by campaign |
| `google_ads_list_ads` | Individual ads with final URL |
| `google_ads_get_keywords` | Keywords with performance |
| `google_ads_get_search_terms` | Search terms that triggered ads (filters: `min_spend`, `conversion_filter`, `aggregate_cross_campaign`) |
| `google_ads_get_metrics` | Aggregated or daily metrics (campaign or account) |
| `google_ads_get_breakdown` | Generic breakdown by `network`, `hour_of_day`, `day_of_week`, or `conversion_action` (levels: campaign / ad_group / account) |
| `google_ads_get_geographic_performance` | Performance by location |
| `google_ads_list_audience_performance` | Performance by audience (includes CUSTOM_INTENT) |
| `google_ads_get_budget_analysis` | "Budget drains" — high cost + low ROAS |
| `google_ads_get_performance_summary` | Executive summary, period comparison, alerts |
| `google_ads_list_change_events` | Detailed change history (CREATE/UPDATE/REMOVE) with per-field diff — last 30 days |
| `google_ads_list_change_status` | Lightweight indicator of recent changes per resource (no diff) |

All accept: flexible date ranges (`last_7_days`, `last_30_days`, `this_month`, `last_month`, custom `YYYY-MM-DD`), output format (`markdown` or `json`), and optional `customer_id`.

The synthesis tools `google_ads_find_wasted_spend` and `google_ads_diagnose_campaign_health` also accept a `language` parameter (`"pt"` or `"en"`, default `"pt"`) that controls the stopword set used to group search terms by their first meaningful word.

---

## Setup A — stdio mode (single-tenant)

### 1. Prerequisites
- Python 3.11+
- Google Ads account
- Project in [Google Cloud Console](https://console.cloud.google.com/) with the **Google Ads API** enabled
- Approved Developer Token ([how to get one](https://developers.google.com/google-ads/api/docs/first-call/dev-token))

### 2. Installation
```bash
git clone <repo>
cd google-ads-mcp
uv sync
```

### 3. "Desktop" OAuth Client in GCP
1. `APIs & Services → Credentials → Create Credentials → OAuth client ID`
2. Application type: **Desktop app**
3. Save the `Client ID` and `Client secret`

### 4. Refresh token
Interactive wizard:
```bash
uv run python scripts/get_refresh_token.py
```
Paste the `Client ID`/`Client secret` when prompted; it opens the browser, you log in, and copy the resulting `refresh_token`.

### 5. `.env`
```env
GOOGLE_ADS_DEVELOPER_TOKEN=...
GOOGLE_ADS_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_ADS_CLIENT_SECRET=...
GOOGLE_ADS_REFRESH_TOKEN=...
GOOGLE_ADS_CUSTOMER_ID=123-456-7890
# Optional, if the account above is under an MCC:
# GOOGLE_ADS_LOGIN_CUSTOMER_ID=999-888-7777
```

### 6. Client configuration

**Claude Desktop** — `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
  "mcpServers": {
    "google-ads": {
      "command": "uv",
      "args": ["run", "python", "main.py", "--transport", "stdio"],
      "cwd": "/path/to/google-ads-mcp"
    }
  }
}
```

**Cursor** — `.cursor/mcp.json`:
```json
{
  "mcpServers": {
    "google-ads": {
      "command": "uv",
      "args": ["run", "python", "main.py", "--transport", "stdio"],
      "cwd": "/path/to/google-ads-mcp"
    }
  }
}
```

`uv run` loads the `.env` automatically via `python-dotenv`.

---

## Setup B — HTTP multi-tenant mode

### How it works

```
Claude Desktop ──(MCP OAuth: tokens issued by us)──▶ our MCP (AS+RS)
                                                          │
                                                          └──(Google OAuth: user's refresh_token)──▶ Google Ads API
```

There are **two chained OAuth flows**: the server is simultaneously an Authorization Server (issues its own JWTs to Claude Desktop) and a Google OAuth Client (holds the encrypted refresh_token for the user's Google account). Desktop **never** sees the Google refresh_token.

UX in Claude Desktop:
1. User adds the MCP URL under Settings → Connectors (once).
2. Clicks "Connect" → browser opens → Google login → `adwords` consent → returns connected.
3. From then on, all calls are authenticated. Refresh is silent.

### 1. Prerequisites
- Public domain with TLS (e.g., `mcp.your-domain.com`)
- Caddy (or Nginx) **running on the host** — will terminate TLS and reverse-proxy to `127.0.0.1:8000`
- Docker + Docker Compose

### 2. Developer Token
Same as Setup A (step 1). It belongs to **the MCP operator** and is shared across all users.

### 3. "Web application" OAuth Client in GCP

> ⚠️ **Separate** client from the Desktop one used in Setup A.

1. Enable the Google Ads API if you haven't already.
2. Configure the **OAuth consent screen**:
   - User type: **External**
   - Add the scope `https://www.googleapis.com/auth/adwords`
   - Under "Test users" add the emails that will test (or click **Publish** — the `adwords` scope is restricted and Google may require verification).
3. **Create Credentials → OAuth client ID**:
   - Application type: **Web application**
   - **Authorized redirect URIs**: `https://mcp.your-domain.com/auth/callback` (must match the `MCP_PUBLIC_URL` you configure **exactly**)
4. Save the `Client ID` and `Client secret`.

### 4. (Optional) Generate a JWT signing key

```bash
# Only if you want to pin the key (e.g., multiple replicas).
# Without this, GoogleProvider derives the key from the client_secret.
uv run python -c "import secrets; print(secrets.token_urlsafe(48))"
```

### 5. `.env`

```env
# MCP operator (shared)
GOOGLE_ADS_DEVELOPER_TOKEN=AbCdEf123...

# Web OAuth Client (step 3)
GOOGLE_OAUTH_WEB_CLIENT_ID=123456789-abc.apps.googleusercontent.com
GOOGLE_OAUTH_WEB_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxx

# Public URL — no trailing slash, with https
MCP_PUBLIC_URL=https://mcp.your-domain.com

# Optional: pinned key for signing MCP JWTs (step 4)
# OAUTH_JWT_SIGNING_KEY=Z9K1u7c-paste-the-output-of-secrets-token-urlsafe-48

# Server
MCP_TRANSPORT=http
MCP_HOST=0.0.0.0
MCP_PORT=8000
```

Setup A variables (`GOOGLE_ADS_CLIENT_ID`, `GOOGLE_ADS_CLIENT_SECRET`, `GOOGLE_ADS_REFRESH_TOKEN`, `GOOGLE_ADS_CUSTOMER_ID`) are **not used** in HTTP mode — you can remove or comment them out.

### 6. Host Caddy

```caddyfile
mcp.your-domain.com {
    reverse_proxy 127.0.0.1:8000
}
```

### 7. Start
```bash
mkdir -p data
docker compose up -d --build
docker compose logs -f google-ads-mcp
```

### 8. Verification
```bash
curl https://mcp.your-domain.com/.well-known/oauth-authorization-server
```
Should return JSON with `issuer`, `authorization_endpoint`, etc.

### 9. Connect from Claude Desktop
Settings → Connectors → Add custom connector → paste `https://mcp.your-domain.com/mcp` → click **Connect** → Google flow opens → log in → account authorized.

Done. Other users can do the same on their machines.

---

## Environment variables

### Common (both modes)
| Variable | Description |
|---|---|
| `GOOGLE_ADS_DEVELOPER_TOKEN` | Google Ads API developer token |
| `MCP_TRANSPORT` | `stdio` (default) or `http` |
| `MCP_PORT` | HTTP port (default `8000`) |
| `MCP_HOST` | HTTP host (default `0.0.0.0`) |

### stdio mode
| Variable | Required | Description |
|---|---|---|
| `GOOGLE_ADS_CLIENT_ID` | ✅ | Desktop OAuth Client ID |
| `GOOGLE_ADS_CLIENT_SECRET` | ✅ | Desktop OAuth Client Secret |
| `GOOGLE_ADS_REFRESH_TOKEN` | ✅ | Refresh token generated by `scripts/get_refresh_token.py` |
| `GOOGLE_ADS_CUSTOMER_ID` | ⚠️ | Default account ID (can be passed per call) |
| `GOOGLE_ADS_LOGIN_CUSTOMER_ID` | — | MCC ID, if applicable |

### HTTP multi-tenant mode
| Variable | Required | Description |
|---|---|---|
| `MCP_PUBLIC_URL` | ✅ | Public URL, no trailing `/`. Must match the redirect URI in GCP |
| `GOOGLE_OAUTH_WEB_CLIENT_ID` | ✅ | Web OAuth Client ID |
| `GOOGLE_OAUTH_WEB_CLIENT_SECRET` | ✅ | Web OAuth Client Secret |
| `OAUTH_JWT_SIGNING_KEY` | — | Optional. If absent, `GoogleProvider` derives the key from the client_secret. Set only for multiple replicas |

---

## Architecture

```
google-ads-mcp/
├── main.py                       # Entry point — stdio or HTTP (mcp.http_app)
├── src/
│   ├── server.py                 # 14 MCP tools (fastmcp) + conditional GoogleProvider
│   ├── client.py                 # REST/GAQL client (Google Ads API v24)
│   ├── auth.py                   # GoogleAdsAuth — env vars (stdio) or injected access_token (HTTP)
│   └── formatters.py             # Markdown / JSON
├── scripts/
│   ├── get_refresh_token.py      # OAuth wizard (stdio mode)
│   └── update_geo_targets.py     # Updates src/data/geo_targets.json
├── Dockerfile
├── docker-compose.yml            # Exposes 127.0.0.1:8000 (host Caddy handles TLS)
├── pyproject.toml
└── .env.example
```

**Data flow (HTTP mode):** `tool call → JWT verified by GoogleProvider (token-swap JTI → decrypted upstream Google access_token) → get_access_token().token → GoogleAdsAuth.for_access_token → Google Ads REST v24 → formatter`. The Google refresh_token stays encrypted in `GoogleProvider`'s internal key-value store; refresh is transparent when the access_token expires.

---

## Usage examples (natural language)

```
"List active campaigns from the last 7 days"
"Which campaign has the worst ROAS in the last month?"
"Show daily metrics for campaign 123456 over the last 14 days"
"Which keywords are spending a lot without converting?"
"Which search terms appeared most in the last 30 days?"
"Show performance by state in the last month"
"Which audiences are performing best?"
"Analyze the account's budget and identify drains"
"Give me an executive summary comparing this week to the previous one"
```

---

## Operations

### Logs
```bash
docker compose logs -f google-ads-mcp
```

### Backup
- `GoogleProvider` persists DCR clients and encrypted Google refresh_tokens in its own key-value store (default: file inside the container's `platformdirs` — to survive restarts, mount the directory on the host).
- `OAUTH_JWT_SIGNING_KEY` — if you change it (or if you're using the default derivation and change `GOOGLE_OAUTH_WEB_CLIENT_SECRET`), all users must reconnect.

### Rebuild after code changes
```bash
docker compose up -d --build
```

### Update the geo targets table
```bash
uv run python scripts/update_geo_targets.py            # default: BR
uv run python scripts/update_geo_targets.py --country BR AR MX
git add src/data/geo_targets.json && git commit
```

---

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `redirect_uri_mismatch` in Google consent | URI in GCP ≠ `${MCP_PUBLIC_URL}/auth/callback` | Check character by character (https, no trailing slash in `MCP_PUBLIC_URL`) |
| "Google did not return a refresh_token" in the callback | User previously authorized the app (Google only returns `refresh_token` on the first consent) | Ask them to revoke at [myaccount.google.com/permissions](https://myaccount.google.com/permissions) and reconnect |
| `401 invalid_token` on `/mcp` | MCP JWT expired or `OAUTH_JWT_SIGNING_KEY` changed | Desktop refreshes on its own; if it persists, reconnect from Connectors |
| Tool returns `PERMISSION_DENIED` from the Google Ads API | User doesn't have access to the requested `customer_id`, or wrong MCC | MCC auto-resolve tries to sort it out; if it fails, check access in Google Ads |
| Docker healthcheck failing | Missing `MCP_PUBLIC_URL` or some `OAUTH_*` in `.env` | `docker compose logs google-ads-mcp` shows which variable is missing |
| `OPENID_DISCOVERY_FAILED` in Claude Desktop | Broken DNS/TLS or Caddy not routing | `curl -v https://mcp.../.well-known/oauth-authorization-server` |

---

## Security

- **Credentials never in code** — always in `.env` (already in `.gitignore`).
- **Host Caddy** — terminates TLS. The container only listens on `127.0.0.1:8000`, not reachable directly from the internet.
- **Rate limiting** — configure on the host Caddy, not in the app.
- **Container runs as a non-root user.**
- **Google refresh_tokens encrypted at rest** by `GoogleProvider`'s internal key-value store.
- **Short-lived MCP JWTs** with transparent upstream Google refresh and MCP refresh token rotation (OAuth 2.1) — all managed by `GoogleProvider`.
- Treat `OAUTH_JWT_SIGNING_KEY` (if set) and `GOOGLE_OAUTH_WEB_CLIENT_SECRET` as critical secrets (vault/secret manager in production).