Skip to main content
Glama
Mostafa-Ghanem

Grenada Google Ads MCP

README.md
# Grenada Google Ads MCP

Multi-user remote **Model Context Protocol** connector for Google Ads, running on Cloudflare Workers.

- **Owner:** Mostafa Ghanem — مصطفى غانم
- **Organization:** Grenada Studio
- **Support:** m.ghanem@grenadastudio.com
- **Runtime:** Cloudflare Workers · D1 · KV
- **Transport:** Remote MCP over Streamable HTTP at `POST /mcp`
- **Google Ads:** REST API, version pinned by a single environment variable

---

## What it does

Any authorised user signs in with their own Google account, and every Google Ads account they can
reach becomes available to their MCP client (Claude, ChatGPT, Codex, MCP Inspector…).

Guarantees baked into the server, not left to the model:

| Rule | Where it is enforced |
| --- | --- |
| The user is never asked for an MCC / `login_customer_id` | `src/google-ads/account-discovery.ts`, `account-resolver.ts` |
| A user can only touch accounts discovered for *their* user id | `resolveAccountContext` — ownership check on every call |
| Manager accounts can never return metrics | `account-resolver.ts` + `gaql.ts` |
| Campaigns/ad groups/ads/keywords are created `PAUSED` | every `create_*` tool |
| A daily budget is mandatory, no silent default | `validateDailyBudgetMicros` |
| Enabling, budget raises and deletions need an explicit one-time confirmation | `src/mcp/confirmations.ts` |
| Read-only GAQL, always bounded by a LIMIT | `src/google-ads/gaql.ts` |
| Per-user rate limits (60 read / 10 write / 20 GAQL / 2 mutate per min) | `src/mcp/permissions.ts` |
| Refresh tokens AES-GCM encrypted, never logged | `src/crypto/aes-gcm.ts`, `src/db/audit.ts` |
| Every write recorded in an audit log | `src/db/audit.ts` |

---

## Tools — 62 implemented

29 read-only · 25 medium-risk · 8 high-risk (confirmation always required).

| Area | Tools |
| --- | --- |
| **Accounts** (4) | `list_google_ads_customers` `get_google_ads_account_info` `refresh_google_ads_accounts` `send_google_ads_invitation`¹ |
| **Campaigns** (9) | `get_google_ads_campaigns` `get_google_ads_campaign_metrics` `create_google_ads_campaign` `update_google_ads_campaign` `pause_google_ads_campaign` `enable_google_ads_campaign`¹ `set_google_ads_geo_targeting` `set_google_ads_language_targeting` `update_google_ads_network_settings` |
| **Ad groups** (4) | `get_google_ads_ad_groups` `create_google_ads_ad_group` `get_google_ads_ad_group_metrics` `update_google_ads_ad_group` |
| **Ads** (6) | `get_google_ads_ads` `create_google_ads_responsive_search_ad` `get_google_ads_ad_metrics` `pause_google_ads_ad` `enable_google_ads_ad`¹ `update_google_ads_video_ad_text` |
| **Keywords** (11) | `get_google_ads_keywords` `add_google_ads_keywords` `add_google_ads_negative_keywords` `get_google_ads_negative_keywords` `get_google_ads_keyword_metrics` `get_google_ads_keyword_ideas` `pause_google_ads_keyword` `enable_google_ads_keyword`¹ `update_google_ads_keyword_bid` `remove_google_ads_keywords`¹ `remove_google_ads_negative_keywords`¹ |
| **Reports** (6) | `get_google_ads_search_terms_report` `get_google_ads_geo_performance` `get_google_ads_device_performance` `get_google_ads_hour_of_day_performance` `get_google_ads_auction_insights` `get_google_ads_bidding_strategy_report` |
| **Assets** (3) | `list_google_ads_assets` `upload_google_ads_asset` `create_google_ads_image_assets_from_urls` |
| **Audiences** (3) | `get_google_ads_audiences` `create_google_ads_custom_audience` `add_google_ads_audience_to_campaign` |
| **Billing** (2) | `list_google_ads_invoices` `download_google_ads_invoice_pdf` |
| **Extensions** (6) | `create_google_ads_sitelink` `create_google_ads_callout` `create_google_ads_structured_snippet` `get_google_ads_extensions` `update_google_ads_extension_status` `remove_google_ads_extension`¹ |
| **Performance Max & Shopping** (4) | `create_google_ads_pmax_campaign` `get_google_ads_pmax_asset_groups` `update_google_ads_pmax_asset_group` `create_google_ads_shopping_listing_group_tree` |
| **Advanced** (4) | `execute_google_ads_gaql_query` `execute_google_ads_mutate`¹ `query_google_ads_api_docs` `submit_feedback` |

¹ high risk — answers `CONFIRMATION_REQUIRED` with a one-time token before doing anything.

### Deviations from the reference catalog

Two of the 62 names in `TOOL_CATALOG.md` describe features of the reference *product*, not of the
Google Ads API, so they were rebuilt natively instead of copied:

| Reference tool | What this project ships | Why |
| --- | --- | --- |
| `create_creatives_from_dropbox_folder` | `create_google_ads_image_assets_from_urls` | Bulk-uploads images from any public URL (including Dropbox direct links) with per-URL error reporting, instead of binding the connector to one storage vendor. |
| `manage_account_slots` | `refresh_google_ads_accounts` | "Account slots" are a billing-plan concept of the reference service. This connector has no slots: every discovered account is usable, so the equivalent action is re-running discovery. |

Two more are worth knowing about because the API constrains them:

- `get_google_ads_auction_insights` returns **your own** impression-share and top/absolute-top
  position metrics. The Google Ads API does not expose the competitor rows shown in the
  Auction Insights UI report — no implementation can return those.
- `query_google_ads_api_docs` answers from a curated GAQL reference compiled into the Worker
  (`src/google-ads/api-docs.ts`), so it needs no third-party documentation service and works
  before a Google account is even connected.

---

## Placeholder credentials

The repository ships with **placeholders only** — there is not a single real secret in it.

`.dev.vars.example` lists the five values you replace before going live:

```
GOOGLE_OAUTH_CLIENT_ID          PLACEHOLDER_GOOGLE_OAUTH_CLIENT_ID.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET      PLACEHOLDER_GOOGLE_OAUTH_CLIENT_SECRET
GOOGLE_ADS_DEVELOPER_TOKEN      PLACEHOLDER_DEVELOPER_TOKEN
TOKEN_ENCRYPTION_KEY            PLACEHOLDER_TOKEN_ENCRYPTION_KEY_CHANGE_ME
MCP_COOKIE_ENCRYPTION_KEY       PLACEHOLDER_COOKIE_ENCRYPTION_KEY_CHANGE_ME
```

The whole app boots, serves every page and passes every test with these placeholders in place; only
the calls that actually reach Google will fail until they are replaced.

Encryption keys: any string works (it is stretched with SHA-256), but generate real ones for
production with `openssl rand -base64 32`.

---

## Local development

```bash
npm install
```

```bash
cp .dev.vars.example .dev.vars
```

```bash
npx wrangler d1 migrations apply DB --local
```

```bash
npm run dev
```

Then check `http://127.0.0.1:8787/health`, `/`, `/privacy`, `/terms`, `/data-deletion`.

Run the checks:

```bash
npm run typecheck && npm test
```

---

## Deployment

### The short way

Authenticate once, in an interactive terminal:

```bash
npx wrangler login
```

Then run the deploy script — it creates the D1 database and both KV namespaces, writes their ids
into `wrangler.jsonc`, generates and stores the encryption keys, pushes every secret, applies the
migrations, deploys, and prints the live URL plus the exact redirect URI to register with Google:

```bash
pwsh ./deploy.ps1
```

Once you have the real Google credentials, re-run it with them:

```bash
pwsh ./deploy.ps1 -GoogleClientId "...apps.googleusercontent.com" -GoogleClientSecret "GOCSPX-..." -DeveloperToken "..."
```

The script is safe to re-run: existing resources are reused and the encryption keys are generated
only once, into the git-ignored `.deploy-keys.local.txt`. **Back that file up** — losing
`TOKEN_ENCRYPTION_KEY` makes every stored Google refresh token unreadable and forces all users to
reconnect.

### The manual way

### 1. Cloudflare resources

```bash
npx wrangler login
```

```bash
npx wrangler d1 create grenada_google_ads_mcp
```

```bash
npx wrangler kv namespace create OAUTH_KV
```

```bash
npx wrangler kv namespace create CACHE_KV
```

Copy the printed ids into `wrangler.jsonc`, replacing `REPLACE_WITH_D1_DATABASE_ID`,
`REPLACE_WITH_OAUTH_KV_ID` and `REPLACE_WITH_CACHE_KV_ID`.

### 2. Secrets

```bash
npx wrangler secret put GOOGLE_OAUTH_CLIENT_ID
```

```bash
npx wrangler secret put GOOGLE_OAUTH_CLIENT_SECRET
```

```bash
npx wrangler secret put GOOGLE_ADS_DEVELOPER_TOKEN
```

```bash
npx wrangler secret put TOKEN_ENCRYPTION_KEY
```

```bash
npx wrangler secret put MCP_COOKIE_ENCRYPTION_KEY
```

### 3. Migrate and deploy

```bash
npx wrangler d1 migrations apply DB --remote
```

```bash
npm test && npm run deploy
```

`wrangler deploy` prints the live URL. Put that URL in `vars.PUBLIC_BASE_URL` in `wrangler.jsonc`
and deploy once more, so the OAuth metadata advertises the right issuer.

### 4. Google Cloud Console

In the OAuth client (type: **Web application**) add the authorised redirect URI:

```
https://<your-worker-url>/google/callback
```

Publish the OAuth consent screen with the scope `https://www.googleapis.com/auth/adwords`, and point
the privacy policy and terms links at `/privacy` and `/terms`.

### 5. Custom domain (optional, last)

Once `grenadastudio.com` is on Cloudflare, uncomment the `routes` block in `wrangler.jsonc`, set
`PUBLIC_BASE_URL` to `https://google-ads-mcp.grenadastudio.com`, redeploy, and add the matching
redirect URI in Google Cloud Console.

---

## Connecting a client

Add the remote MCP server:

```
https://<your-worker-url>/mcp
```

The client performs dynamic client registration at `/register`, is redirected through `/authorize`
to Google, and receives its token from `/token`. One Google sign-in covers both the MCP session and
the Google Ads grant.

Verify manually with the MCP Inspector, or directly:

```bash
curl -s https://<your-worker-url>/health
```

---

## Rollback

```bash
npx wrangler deployments list
```

```bash
npx wrangler rollback --message "reverting to previous version"
```

D1 migrations are additive; rolling the Worker back does not require a schema change.

---

## Project layout

```
src/
  index.ts                  HTTP router
  mcp/
    server.ts               JSON-RPC dispatch, tool middleware, audit
    permissions.ts          risk map + rate limits
    confirmations.ts        two-phase confirmation for high-risk writes
    tools/                  accounts, campaigns, ad-groups, ads, keywords, reports,
                            assets, audiences, billing, extensions, pmax, advanced
  auth/
    mcp-oauth.ts            OAuth 2.1 authorization server for MCP clients
    google-oauth.ts         Google consent, code exchange, refresh, revoke
    token-store.ts          encrypted refresh token + cached access token
  google-ads/
    client.ts               single REST choke point, version from env
    account-discovery.ts    listAccessibleCustomers + customer_client tree walk
    account-resolver.ts     ownership check + login-customer-id decision
    gaql.ts                 read-only GAQL sanitiser, date filters
    mutate.ts               mutate helpers, atomic multi-resource writes, update masks
    api-docs.ts             curated GAQL reference used by query_google_ads_api_docs
    errors.ts               Google error -> internal error code
  db/repositories.ts        D1 access
  db/audit.ts               audit log + redaction
  crypto/aes-gcm.ts         AES-GCM, HMAC, SHA-256, base64, PKCE helpers
  routes/pages.ts           home, privacy, terms, data deletion, account
  routes/google.ts          connect, callback, disconnect, refresh
migrations/0001_initial.sql
migrations/0002_feedback.sql
test/                       122 unit + security tests
```

---

## Acceptance checklist

- [x] Worker bundles and runs (`wrangler deploy --dry-run`, `wrangler dev`)
- [x] `/health` responds
- [x] `POST /mcp` returns 401 with `WWW-Authenticate` when unauthenticated
- [x] OAuth discovery metadata served at both `.well-known` paths
- [x] Dynamic client registration works; unknown clients are rejected at `/authorize`
- [x] PKCE `S256` required; redirect URIs matched exactly
- [x] `initialize` and `tools/list` answer over `/mcp` (62 tools)
- [x] Structured error envelopes (`GOOGLE_NOT_CONNECTED`, `VALIDATION_ERROR`, `RATE_LIMITED`)
- [x] Rate limiting verified live: the 21st raw-GAQL call in a minute is rejected
- [x] Confirmation lifecycle verified live: token issued → tampered arguments rejected →
      identical arguments accepted → replay of the same token rejected
- [x] Manager-account guard verified live on a reporting tool
- [x] Google consent URL carries `access_type=offline`, `prompt=consent`, `include_granted_scopes=true`
- [x] D1 migrations apply from scratch
- [x] No secrets in the repository
- [x] Privacy, Terms and Data deletion pages present
- [x] `npm run typecheck` clean, `npm test` green (122 tests)
- [ ] End-to-end run against real Google credentials — needs the real developer token and OAuth client

Maintenance

ActivitySlowing
ResponsivenessNo issues