Guarded Google Ads MCP
Provides tools for reading and changing a Google Ads account, including campaign, ad group, ad, budget, bid, negative keyword, recommendation, and conversion action management. Writes are previewed with Google validation, require explicit confirmation, and are subject to server-side guardrails and audit logging.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
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.

What Claude can do
Kind | Tools |
Read (10) |
|
Change (9) |
|
Create (4) |
|
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_idis narrow. It is bound to one user, one tool and one account, expires after 15 minutes, and can be consumed once. Consumption is anUPDATE … 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:mutatewith 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 |
Kill switch |
|
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 |
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 |
|
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
openidscope made the token endpoint fail with "Error generating ID token". The server now namesemail profilein both the401header 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_accountsreturns aserver_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 tokenThe 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 flowSet it up
You need a Supabase project, a Google Cloud project, and a Vercel account. Allow about 30 minutes.
1. Supabase
Create a project.
Run the migration. Paste
supabase/migrations/0001_google_ads_mcp.sqlinto the SQL editor, or runsupabase linkand thensupabase db push.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.
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.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.
Copy the project URL, the publishable (anon) key and the secret (service role) key from the project's API settings.
2. Google Cloud
Create or pick a project and enable the Google Ads API.
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.
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
Import this repository.
Set the environment variables from
.env.exampleand deploy.APP_URLdefaults to the production domain. Set it if you use a custom domain, and make it match Supabase's Site URL.Generate the encryption key with:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"4. Connect
Open the app and sign in. The dashboard shows your user id.
Put the id in
ADS_MCP_ALLOWED_USER_IDSand redeploy.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_IDin that case.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.
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.pyLocal 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 devCreate 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 |
| on custom domains | Public URL of the app. Defaults to the Vercel production domain. |
| yes | Supabase project URL and publishable (anon) key. |
| yes | Supabase secret (service role) key. Server only. |
| yes | The Google OAuth client. |
| yes | 32 random bytes, base64. Encrypts the Google refresh token. Changing it means reconnecting Google. |
| yes | Comma-separated Supabase user ids. Empty means nobody. |
| to create ads | Hosts a new ad may link to. Empty means no ad can be created. |
| no | Account the tools act on when a call names none. |
| no | Manager account to act through. Only needed when the target sits under a manager your login cannot reach directly. |
| no | Ignored by Google since 2026-09-09. Sent if set. |
| no |
|
| no | Default 50. |
| no | Default 500, in account currency. |
| no | Defaults to the max daily budget. |
| no | Defaults 50 and 50. |
| 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 |
Token endpoint returns 500 "Error generating ID token" | A client asked for |
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 | The wrong |
| The Google consent screen is still in Testing mode. Publish it, then reconnect. |
Decrypt errors such as "unable to authenticate data" |
|
Claude shows old tools after a deploy | Clients cache the tool list. Restart the client, and check |
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.pyand 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.
This server cannot be deployed
Maintenance
Related MCP Connectors
Run Google Ads and Meta Ads from ChatGPT or Claude: audit wasted spend, create and manage campaigns.
Manage Google Ads from ChatGPT, Claude or any MCP client. Sign in with OAuth or use an API key.
Run Google Ads, Meta Ads, LinkedIn Ads and ChatGPT Ads from Claude or ChatGPT. You approve every change.
Build, edit and sync Google, Microsoft, Reddit and Meta ad campaigns from your assistant.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceEnables Claude Desktop to access and manage Google Ads data, including campaigns, ad groups, keywords, budgets, and visualizations, through natural language interactions.19-
- AlicenseNot gradedqualityDmaintenanceConnects Claude Desktop to your Google Ads account, allowing you to analyze and manage campaigns, ad groups, and keywords using natural language.MIT
- AlicenseNot gradedqualityCmaintenanceEnables natural-language analysis and management of Google Ads accounts, campaigns, keywords, budgets, and performance metrics through Claude.MIT
- AlicenseAqualityBmaintenanceEnables Claude to read and search an Amazon Ads account, pull suggested bids and reports, and propose changes as approved plans with rollback support.16Apache 2.0