Skip to main content
Glama
manuhelpiz

Guarded Google Ads MCP

by manuhelpiz

Guarded Google Ads MCP

A remote MCP 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. 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.

The operator dashboard: readiness checks, the Google connection, guardrails and recent writes

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.

Related MCP server: Google Ads MCP Server

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), 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, 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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Connects Claude Desktop to your Google Ads account, allowing you to analyze and manage campaigns, ad groups, and keywords using natural language.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables natural-language analysis and management of Google Ads accounts, campaigns, keywords, budgets, and performance metrics through Claude.
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Enables Claude to read and search an Amazon Ads account, pull suggested bids and reports, and propose changes as approved plans with rollback support.
    16
    Apache 2.0