Guarded Google Ads MCP
by manuhelpiz
README.md
# Guarded Google Ads MCP
A remote [MCP](https://modelcontextprotocol.io) server that lets Claude read and change a Google Ads account. Every write is validated by Google before a person approves it, spending limits are enforced on the server, and every change lands in an audit log.
> **These tools can spend real money.** Every change is previewed and needs an explicit confirm, and new campaigns and ads are always created paused. A confirmed budget or bid change still takes effect in Google Ads immediately.
It runs as a small Next.js app on Vercel, with Supabase for sign-in (its OAuth 2.1 server) and storage. No third party holds your Google credentials: the refresh token is encrypted in your own database and never appears in tool output.
Google publishes an [official Google Ads MCP server](https://developers.google.com/google-ads/api/docs/developer-toolkit/mcp-server). In its current release it is read-only: it "cannot modify bids, pause campaigns, or create new assets" (checked November 2026). This project is about the write side: what it takes to let a model make changes to a revenue system safely.

## What Claude can do
| Kind | Tools |
| --- | --- |
| Read (10) | `list_accounts`, `account_summary`, `campaign_performance`, `search_terms`, `run_gaql`, `list_recommendations`, `list_ads`, `suggest_locations`, `list_conversion_actions`, `list_negative_keyword_lists` |
| Change (9) | `set_campaign_status`, `set_ad_group_status`, `set_ad_status`, `update_budget`, `update_bid`, `add_negative_keywords`, `update_negative_keyword_list`, `update_conversion_action`, `apply_or_dismiss_recommendation` |
| Create (4) | `create_search_campaign`, `create_ad_group`, `create_responsive_search_ads`, `create_negative_keyword_list` |
Nothing can be removed. Search campaigns with responsive search ads are the only campaign type it creates.
## Design notes
### Every write is a preview, then a confirm
Each change tool runs twice. The first call (`confirm: false`, the default) builds the exact Google Ads REST request, sends it to Google with `validateOnly: true`, and stores the request under a `change_id`. It returns a before/after diff, warnings, and whether Google accepted it. Claude shows that to the person. Only a second call with `confirm: true` and the `change_id` executes, and it replays the stored request byte for byte.
Why this shape:
- **The person approves what will actually be sent.** The confirm carries no parameters, so the model cannot change the payload between preview and execution.
- **Google's judgement arrives before the person's, not after.** A preview that Google would reject never reaches the approval step.
- **A `change_id` is narrow.** It is bound to one user, one tool and one account, expires after 15 minutes, and can be consumed once. Consumption is an `UPDATE … WHERE consumed_at IS NULL`, so two confirms cannot both win.
- **Creation is atomic.** A new campaign (budget, campaign, targeting, ad groups, keywords, ads) goes to Google as one `googleAds:mutate` with temporary ids, so a half-built campaign cannot exist.
*What production taught:* on day two, previews of conversion-action changes came back rejected. Google treats counting type and lookback windows on GA4-imported actions as immutable through the API (`IMMUTABLE_FIELD`), and refuses any change to YouTube-hosted actions (`MUTATE_NOT_ALLOWED`). Nothing had been applied, because nothing is applied at preview. The tool now checks where an action comes from first and explains the limit in plain words.
### Guardrails live on the server, not in the prompt
A prompt can be argued with; a server-side check cannot. What the server refuses:
| Area | Check |
| --- | --- |
| Who | Only Supabase users in `ADS_MCP_ALLOWED_USER_IDS`. Everyone else can sign in but gets `403 insufficient_scope` on every MCP call. |
| Kill switch | `ADS_MCP_READ_ONLY=true` makes every write refuse at preview and at confirm. It is an environment change, not a code change. |
| Budgets | Max percent change per call, max daily amount, max starting budget for a new campaign. Shared budgets are flagged with every campaign that would change. |
| Bids | Max percent change, max absolute CPC or target CPA, and the bid type must match the campaign's bidding strategy. |
| Creation | Campaigns and ads are created paused. Ad landing pages must be https on a host in `ADS_MCP_ALLOWED_FINAL_URL_HOSTS`, which has no default. Ad copy is checked against Google's limits first, so problems come back in plain words. |
| Removals | No tool removes anything. Recommendations that remove things are refused. |
| Conversions | Refuses to demote the last primary conversion action, which would leave Smart Bidding nothing to optimise for. |
| Reads | `run_gaql` accepts one `SELECT`, refuses fields that identify people (user access, emails, billing), and caps rows. |
*What production taught:* most of these were designed up front. The last-primary rule came from use. A real plan to demote six of seven primary conversion actions in one sitting showed how one wrong call would silently break bidding, so the server now refuses it.
### The audit log records attempts, not only changes
Every preview, every confirm and every guardrail rejection writes a row to `google_ads_audit_log`. A row records:
- the Supabase user, the tool, the action and the account;
- the `change_id`, the before and after values, and the exact request;
- Google's response and Google's request id, plus status and error.
The dashboard shows the last 20. Nothing outside the server writes to the table: it is service-role only, with row-level security on and no grants to the public roles.
*What production taught:* this was designed in, not learned. Logging refused and failed previews turned out to be the useful part. The history shows what Claude tried and what stopped it, not just what changed. Google's request id is what Google's API support asks for.
### Sign-in: OAuth 2.1 through Supabase, and one allowlist
Claude discovers everything from a `401`. The `401` points to the protected-resource metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)), which names Supabase as the authorization server. From there, Claude registers itself through dynamic client registration and runs PKCE. Supabase sends the person to this app's consent page, and Claude gets a Supabase access token. The MCP verifies that token with Supabase on every request. The allowlist then becomes an MCP scope (`ads:manage`), so access is decided in one place. The Google refresh token is encrypted with AES-256-GCM, and the key lives only in the environment.
*What production taught,* three times:
- **openid.** Unless told otherwise, Claude asks for every scope the authorization server lists. On a Supabase project that signed JWTs with HS256, the `openid` scope made the token endpoint fail with "Error generating ID token". The server now names `email profile` in both the `401` header and the metadata. A fresh Supabase stack in November 2026 no longer fails this way, but asking for less is still right.
- **The consent page.** The first version disabled its buttons on submit, to stop double clicks. A disabled button is left out of the form data, so every Approve arrived as a deny. The decision now travels as the clicked button's own value, and the buttons disable one tick later, so a double click submits exactly once.
- **The host.** The claude.ai connector failed with "Couldn't connect" for two weeks while Claude Code and Claude Desktop worked. The cause was the hosting firewall's AI-bot rules blocking Anthropic's servers, not the MCP. See Troubleshooting.
### Tool schemas are the interface
The model reads the schema and the descriptions, nothing else, so they are written as carefully as the code. Money is in account currency, never micros. Every write's description says it is two-step. Errors come back as sentences that say what to do next.
*What production taught:* a Claude session kept reporting that headlines had no pin field, while the server was serving one. The schema was a string-or-object union, and at least one client view showed only the first branch. Three changes fixed it:
- The published schema is now object-only. Bare strings are still accepted at runtime.
- The pin format is spelled out, with an example, in each tool's top-level description.
- `list_accounts` returns a `server_version`, so anyone can check which build answered.
## How it works
```
Claude ── POST /api/mcp, no token ───────────────────▶ 401, resource_metadata=…
── GET /.well-known/oauth-protected-resource/api/mcp ─▶ authorization server: Supabase
── register + authorize (PKCE) ───────────────▶ Supabase Auth, OAuth 2.1 server
└─▶ /oauth/consent here: sign in, Approve
── POST /api/mcp, Bearer <Supabase token> ────▶ verify with Supabase ─▶ allowlist ─▶ tool
│
Google Ads REST v25 ◀── access token from the stored refresh token
```
The MCP endpoint is stateless Streamable HTTP, served by [`mcp-handler`](https://github.com/vercel/mcp-handler), so each request stands alone and nothing needs Redis.
```
src/ads-mcp/ everything specific to the MCP
server.ts MCP endpoint, auth wiring, 401 scope hint
metadata.ts RFC 9728 protected-resource metadata
consent.tsx, decision.ts the OAuth consent page and its form handler
admin-api.ts dashboard endpoints: connect Google, status, disconnect
lib/mcp-tools.ts tool registrations: schemas and descriptions
lib/writes.ts two-step engine + status, budget, bid, negatives, recommendations
lib/campaigns.ts campaign, ad group and ad creation; ad copy validation
lib/conversions.ts conversion action settings
lib/negatives.ts shared negative keyword lists
lib/reads.ts, gaql.ts read tools and the GAQL safety check
lib/changes.ts pending changes and the audit log
lib/connection.ts the encrypted Google grant and per-call context
src/app/ thin Next.js route files that re-export the above
supabase/migrations/ the three tables
scripts/check-connect-flow.py end-to-end check of the connect flow
```
## Set it up
You need a Supabase project, a Google Cloud project, and a Vercel account. Allow about 30 minutes.
### 1. Supabase
1. Create a project.
2. Run the migration. Paste `supabase/migrations/0001_google_ads_mcp.sql` into the SQL editor, or run `supabase link` and then `supabase db push`.
3. Under **Authentication → OAuth Server**:
- Enable the server.
- Turn on **dynamic client registration**. Without it Claude reports "Couldn't register with … sign-in service".
- Set the authorization path to `/oauth/consent`.
4. Under **Authentication → URL Configuration**, set **Site URL** to your app URL, for example `https://your-app.vercel.app`. Supabase sends people to Site URL plus the authorization path.
5. Under **Authentication → Users → Add user**, create your operator with an email and password, auto-confirmed. Then turn off public sign-ups. The app has no sign-up page, and the allowlist is the real gate, but there is no reason to let strangers make accounts.
6. Copy the project URL, the publishable (anon) key and the secret (service role) key from the project's API settings.
### 2. Google Cloud
1. Create or pick a project and enable the **Google Ads API**.
2. Configure the OAuth consent screen as **External**, and publish it to production. In Testing mode, Google expires refresh tokens after 7 days. Google shows an "unverified app" warning for the Ads scope, which is fine for your own account.
3. Create an **OAuth client ID** of type **Web application**, with the authorized redirect URI `https://your-app.vercel.app/api/google-ads/callback`.
Developer tokens are no longer required as of 2026-09-09: Google now ties API access to the Cloud project that owns the OAuth client. A new project starts at Explorer access, which allows 2,880 operations a day.
### 3. Vercel
1. Import this repository.
2. Set the environment variables from `.env.example` and deploy.
3. `APP_URL` defaults to the production domain. Set it if you use a custom domain, and make it match Supabase's Site URL.
4. Generate the encryption key with:
```
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```
### 4. Connect
1. Open the app and sign in. The dashboard shows your user id.
2. Put the id in `ADS_MCP_ALLOWED_USER_IDS` and redeploy.
3. Click **Connect Google Ads** and sign in with the Google login that has access to the Ads account. The dashboard's readiness list should then be all green, except the default account if your login reaches several. Set `GOOGLE_ADS_CUSTOMER_ID` in that case.
4. Connect Claude:
- **claude.ai or Claude Desktop:** Settings → Connectors → Add custom connector, with the URL `https://your-app.vercel.app/api/mcp`. Sign in and Approve.
- **Claude Code:** run `claude mcp add --transport http google-ads https://your-app.vercel.app/api/mcp`, then authenticate from `/mcp`.
5. Check the connect flow end to end, without touching Google Ads:
```
APP=https://your-app.vercel.app PUB=<publishable key> EMAIL=<operator> PASSWORD=<password> \
python3 scripts/check-connect-flow.py
```
## Local development
```
npm install
npx supabase start # Docker; applies the migration, OAuth server on (supabase/config.toml)
cp .env.example .env.local # fill in the URL and keys that `supabase start` printed
npm run dev
```
Create an operator in the local Studio, or through the Auth admin API with the secret key. Local Google sign-in needs `http://localhost:3000/api/google-ads/callback` added to the OAuth client's redirect URIs.
## Configuration
| Variable | Required | Meaning |
| --- | --- | --- |
| `APP_URL` | on custom domains | Public URL of the app. Defaults to the Vercel production domain. |
| `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY` | yes | Supabase project URL and publishable (anon) key. |
| `SUPABASE_SERVICE_ROLE_KEY` | yes | Supabase secret (service role) key. Server only. |
| `GOOGLE_ADS_CLIENT_ID`, `GOOGLE_ADS_CLIENT_SECRET` | yes | The Google OAuth client. |
| `ADS_MCP_ENCRYPTION_KEY` | yes | 32 random bytes, base64. Encrypts the Google refresh token. Changing it means reconnecting Google. |
| `ADS_MCP_ALLOWED_USER_IDS` | yes | Comma-separated Supabase user ids. Empty means nobody. |
| `ADS_MCP_ALLOWED_FINAL_URL_HOSTS` | to create ads | Hosts a new ad may link to. Empty means no ad can be created. |
| `GOOGLE_ADS_CUSTOMER_ID` | no | Account the tools act on when a call names none. |
| `GOOGLE_ADS_LOGIN_CUSTOMER_ID` | no | Manager account to act through. Only needed when the target sits under a manager your login cannot reach directly. |
| `GOOGLE_ADS_DEVELOPER_TOKEN` | no | Ignored by Google since 2026-09-09. Sent if set. |
| `ADS_MCP_READ_ONLY` | no | `true` switches off every write. |
| `ADS_MCP_MAX_BUDGET_CHANGE_PCT` | no | Default 50. |
| `ADS_MCP_MAX_DAILY_BUDGET` | no | Default 500, in account currency. |
| `ADS_MCP_MAX_NEW_CAMPAIGN_BUDGET` | no | Defaults to the max daily budget. |
| `ADS_MCP_MAX_BID_CHANGE_PCT`, `ADS_MCP_MAX_BID` | no | Defaults 50 and 50. |
| `ADS_MCP_GAQL_ROW_CAP` | no | Default 1000. |
## Troubleshooting
| Symptom | Cause and fix |
| --- | --- |
| "Couldn't register with … sign-in service" | Dynamic client registration is off in Supabase. |
| Sign-in works, then claude.ai says "Couldn't connect", while Claude Code connects | Something in front of the app is blocking Anthropic's servers. On Vercel, the firewall's AI-bot managed rules did this. Add a bypass for `/api/mcp` and `/.well-known/`. |
| Token endpoint returns 500 "Error generating ID token" | A client asked for `openid` on a project that signs JWTs with HS256. This server never asks for it. Move the project to asymmetric JWT signing keys, or remove `openid` from the client's request. |
| The consent page says the request expired or is no longer pending | Pending authorizations live a few minutes. Start the connection again and approve right away. |
| Every tool fails with `USER_PERMISSION_DENIED` | The wrong `login-customer-id`. For an account your login reaches directly, the server sends the account's own id. Only set `GOOGLE_ADS_LOGIN_CUSTOMER_ID` when going through a manager account. |
| `invalid_grant` after about a week | The Google consent screen is still in Testing mode. Publish it, then reconnect. |
| Decrypt errors such as "unable to authenticate data" | `ADS_MCP_ENCRYPTION_KEY` changed since Google was connected, or differs between environments that share one database. Reconnect. |
| Claude shows old tools after a deploy | Clients cache the tool list. Restart the client, and check `server_version` in `list_accounts`. |
## Status
- **API version.** Pinned to Google Ads API v25 in `src/ads-mcp/lib/config.ts`. Maintained as time allows, PRs welcome.
- **Two copies.** This is an extraction of the server that runs inside the Fast Response AI app, in production since October 2026. The two copies are maintained separately and will drift.
- **Testing.** The full connect flow was tested against a freshly created Supabase stack in November 2026, by `scripts/check-connect-flow.py` and a headless-browser run of sign-in, consent and a double-clicked Approve. The Google Ads tools have run against a live account since October 2026.
- **Scope.** One Google connection per deployment, meant for one company's own account. Search campaigns only. No keyword planner, no removals.
- **Next.** A local version that runs on your own machine through Claude Desktop over stdio, with your own Google sign-in and no Supabase. Planned, not started.
## License
[MIT](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues