google-ads-agent
google-ads-agent
An MCP server and autonomous agent for Google Ads: build search campaigns, analyse what they're doing, and — optionally — let it make spend-reducing changes on a schedule without a human in the loop.
28 tools. 20 are read-only. One is irreversible and can only be triggered by a person. One reaches every mutable resource in the API and is governed by a permission table rather than by hand-written logic.
The distinguishing idea is that the safety boundary is a policy engine inside the server, not instructions in a prompt. Whatever drives the tools — a cron job, an LLM, you typing — hits the same enforcement point, so the driver is a swappable detail rather than a security control.
⚠️ Disclaimer — read before using
This software modifies live advertising accounts. It can cause money to be spent, cause campaigns to stop serving, and permanently delete campaigns. You use it entirely at your own risk.
Provided "as is", without warranty of any kind. See LICENSE. The authors and contributors accept no liability for any advertising spend, lost revenue, lost data, account suspension, or other damages arising from use or misuse of this software, however caused.
You are solely responsible for everything this software does in your accounts, including in autonomous mode, where it acts without human review of individual changes.
You are responsible for your own compliance with the Google Ads API Terms of Service, the obligations attached to your developer token, and any applicable advertising, privacy, and data-protection law.
Test on a Google Ads test account first. Verify the behaviour you expect before pointing it at an account that spends money.
Campaign removal cannot be undone. Google Ads has no un-remove.
Nothing here is legal, financial, or professional advertising advice.
Not affiliated with Google. This is an independent, unofficial project. It is not created, endorsed, sponsored by, or affiliated with Google LLC. "Google", "Google Ads", and related marks are trademarks of Google LLC, used here only to identify the API this software targets.
Table of contents
What it does
Build. Resolve locations and languages to targeting IDs, get keyword ideas, assemble a campaign spec, validate it against the live API without writing anything, then commit it as a single atomic mutation. Campaigns are always created paused.
Analyse. Performance by campaign, ad group, or ad, segmented by network, device, or date. Per-keyword metrics with quality score. The real search queries that triggered your ads. A structural audit that needs no performance data at all. Raw GAQL when none of that fits.
Act. Turn Display expansion and search partners off, add negative keywords, pause keywords and ad groups — each gated on evidence the server measures itself, with a change budget, per-entity cooldowns, a circuit breaker, and a full audit journal that supports one-command reversal.
Delete. Remove a campaign, behind three simultaneous gates, reachable only by a human.
The safety model
Actions are tiered by spend direction, which is a mechanical property rather than a judgement call:
Tier | Actions | Rule |
autonomous |
| Can only narrow delivery. Worst case: serves too little |
propose | budgets, bids, new campaigns, RSA edits | Can increase spend, so it gets written up for a human instead of executed |
forbidden | enabling/unpausing, removing, raising budgets | No autonomous path and no proposal path |
Unknown actions fail closed to forbidden.
Six properties make that boundary real rather than decorative:
The server measures the evidence; it never accepts it from the caller. An agent that supplies its own justification can fabricate it.
add_negative_keywordsqueries the search-term data itself;set_networksqueries the network split itself.Evidence thresholds. The failure mode of automated ad management is acting on noise. A keyword with 4 clicks and no conversions is not evidence of a bad keyword. Volume floors are enforced per action type.
Conversions protect an entity, but not at any price. A
max_conversions: 0rule alone lets a single token conversion shield unlimited waste, somax_cost_per_conversionoverrides that protection.Pause, never remove. Every autonomous action stores its inverse in the journal, so
revert_last_runcan undo an entire run.Change budget and cooldowns. Caps per run and per week, plus a per-entity cooldown, because frequent changes actively degrade Smart Bidding — it needs stability to relearn.
Circuit breaker. Halts on a spend spike relative to daily budget, or inside a bidding learning period. The learning-period input comes from
change_event, counting only changes made outside the API — so the agent backs off when a person has been editing, without tripping over its own changes. If that history is unavailable the check fails open, andcheck_setupreports the gap.
The trust switch
conversion_tracking_ok defaults to false, and while it is false no
autonomous action is permitted at all.
Every autonomous decision keys off conversions. An agent running against under-firing conversion tracking does not fail loudly — it quietly pauses your best-performing keywords for lacking conversions that were never recorded.
Run conversion_actions to check. It flags the failure signatures that
matter: a primary action recording nothing, lookback windows too short to catch
your sales cycle, lead goals counting many-per-click, and conversion-based
bidding running on too little volume to model anything. If it reports nothing
HIGH, set the switch deliberately.
Full API coverage and the permission table
The tools above cover campaign construction and the four spend-reducing changes.
Everything else the Google Ads API can write is reached through one tool,
mutate, which takes a resource, an operation, and a JSON object of fields.
There are 64 mutable resources in v25, and hand-writing a tool per resource
would be unmaintainable and would swamp the tool list, so the generic path
exists instead.
That path is governed by a permission table rather than by code that understands each resource. A resource, operation or field with no rule in the table is refused. Absence means denial, which is why new API surface never becomes writable by accident: Google adding a resource does not add a rule.
Four tiers, in descending order of trust:
Tier | Meaning |
| Runs unattended, once the server has measured the evidence. Refused if it names an evidence rule the server cannot measure for that resource. |
| Runs when a human approves the exact diff in-session. Unreachable in an unattended run. |
| Never executed. Written up for a person to do. |
| Refused always, however it is confirmed. |
confirm is what makes full coverage possible without making scheduled runs
dangerous. Evidence thresholds, change budgets and circuit breakers substitute
for human judgement, so a present human approving one specific diff supplies it
directly and those checks do not apply. The tier still does, and
agent/run.sh exports ADS_AGENT_UNATTENDED=1 so a scheduled run cannot
self-approve.
Rules are specific about direction and value, because the same field is not one permission. Lowering a budget and raising it are separate grants. Setting a campaign's status to PAUSED and to ENABLED are separate grants. The most specific matching rule wins, and in a call touching several fields the strictest tier present governs the whole call.
Every update reads the current values before writing, which is not optional: it supplies the direction of a numeric change and the undo recorded in the journal. An update whose before-state cannot be read is refused rather than performed blind.
Managing permissions
ads-agent-policy edits the table through validation, and writes only the rules
that differ from the shipped seed, so git diff shows exactly what you chose to
allow.
ads-agent-policy list # what is granted now
ads-agent-policy resources # all 64 mutable resources, marked
ads-agent-policy fields campaign_budget # every field, with its rules
ads-agent-policy show campaign update --field status --value ENABLED
ads-agent-policy grant campaign_budget update --field amount_micros \
--direction increase --tier confirm --note "approved 7 Sep"
ads-agent-policy simulate campaign_budget update --field amount_micros \
--value 40000000 --current 28000000
ads-agent-policy revoke campaign_budget update --field amount_micros \
--direction increase
ads-agent-policy diff # your rules versus the seedgrant refuses to set autonomous without an --evidence rule, because an
unattended write with no volume threshold acts on noise.
The table lives at ~/.ads-agent/capabilities.yaml. The shipped seed grants
nothing autonomously, puts pausing and budget decreases at confirm, and names
the dangerous operations as forbidden so the table documents them rather than
staying silent about them.
Requirements
Python 3.11+
A Google Ads manager account. Developer tokens are issued only to manager accounts — the Admin → API Center menu does not exist on a regular account.
A developer token. Test access is immediate; Basic access is an application and is required for the Keyword Planner tools. See access tiers.
An OAuth client of type Desktop app, in a Google Cloud project with the Google Ads API enabled.
For autonomous mode only: the Claude Code CLI on
PATH. The MCP server itself works with any MCP client.For the bundled scheduler only: macOS (launchd). On Linux use cron with
agent/run.sh.
Install
git clone https://github.com/<your-account>/google-ads-agent.git
cd google-ads-agent
python3 -m venv .venv
./.venv/bin/python -m pip install -e ".[dev]"
./.venv/bin/python -m pytest -q # 90 tests, no credentials neededThe test suite is fully offline: it builds an API client with anonymous credentials and sends no requests, so it passes before you have any credentials.
Configure credentials
1. Developer token
Sign in to your manager account → Admin → API Center. Copy the token. Apply for Basic access from the same page if you need the Keyword Planner tools.
2. OAuth client
Google Cloud Console → enable the Google Ads API → Credentials → Create credentials → OAuth client ID → application type Desktop app.
Desktop app matters: it permits the loopback redirect the token script uses, so you don't have to register a redirect URI. A Web application client will reject the flow.
3. Refresh token
./.venv/bin/python scripts/get_refresh_token.py \
--client-id YOUR_ID.apps.googleusercontent.com \
--client-secret YOUR_SECRET \
--developer-token YOUR_DEV_TOKEN \
--login-customer-id 1234567890 \
--write--write produces ~/google-ads.yaml at mode 600. Without it the token is
only printed. Sign in with an account that can reach the manager account.
--login-customer-id is the manager account's ID, digits only. It selects
the hierarchy you act through, not the account you write to — that is a separate
per-call argument.
If no refresh token comes back, the Google account has already granted this client. Revoke it at myaccount.google.com/permissions and retry; Google returns a refresh token only on first consent.
Set GOOGLE_ADS_DEVELOPER_TOKEN, GOOGLE_ADS_CLIENT_ID,
GOOGLE_ADS_CLIENT_SECRET, GOOGLE_ADS_REFRESH_TOKEN, and optionally
GOOGLE_ADS_LOGIN_CUSTOMER_ID. A config file takes precedence if it exists.
Point GOOGLE_ADS_CONFIGURATION_FILE_PATH elsewhere to move it.
Use it interactively
Register the MCP server with your client. For Claude Code:
claude mcp add -s user google-ads -- \
"$PWD/.venv/bin/python" "$PWD/run_server.py"-s user matters: without it the server registers against the directory you ran
the command in and is invisible everywhere else.
Verify with claude mcp list. Note that "connected" only means the process
starts and speaks MCP — the API client is built lazily, so credential problems
surface on the first tool call, not at startup.
Then ask for what you want:
List my Google Ads accounts.
Search campaign for project management software, 30/day, targeting Spain and the United States, pointing at https://example.com/signup. Preview it first.
Audit campaign 1234567890 and show me last 30 days broken down by network.
Always look at the network split before drawing conclusions from a search
campaign's totals. Display expansion and search-partner spend are folded into
the campaign number, so a campaign that appears to be buying search traffic can
be spending most of its budget elsewhere. performance with segment="network"
is the tool for that.
Run it autonomously
1. Write your policy
mkdir -p ~/.ads-agent
cp policy.yaml.example ~/.ads-agent/policy.yamlEdit it. This file is the agent's mandate and the whole reason it can be trusted
to act unattended, so keep it in version control and review changes to it the
way you would review a permission grant. Anything omitted falls back to the
defaults in ads_agent/policy.py.
The shipped example is deliberately inert: conversion_tracking_ok is false,
so nothing autonomous will run until you verify your conversion tracking and
change it.
2. Run once by hand
./agent/run.shRead the report it writes to ~/.ads-agent/reports/. Do this before scheduling
anything.
3. Schedule it
./agent/install-schedule.sh # daily at 07:15
./agent/install-schedule.sh 6 30 # daily at 06:30
./agent/install-schedule.sh --uninstallPaths are derived from wherever the repo actually lives; nothing is hardcoded. Re-run it after moving the repo.
On Linux, add agent/run.sh to cron instead:
15 7 * * * /path/to/google-ads-agent/agent/run.shWhat a run produces
Path | Contents |
| One dated Markdown report per run: actions taken, proposals, blocked attempts, data-quality notes |
| Full transcript per run |
| Append-only audit trail. Also the input to change-budget and cooldown enforcement |
agent/prompt.md is the standing instruction set — edit it to change what the
agent looks at and how it reports.
create_campaign and remove_campaign are deliberately absent from
run.sh's allowed tools. Both are human-gated, so an unattended run must not be
able to reach them.
Undoing a run
revert_last_run for account 1234567890It replays the inverse operations stored in the journal: re-enabling what was paused and removing negatives the agent itself added. Campaign removals are recorded with an explicitly empty inverse, so they report as unrevertible rather than appearing to be undone.
Tool reference
Build
Tool | Writes | Purpose |
| Accessible accounts with currency, time zone, manager/test flags | |
| Location name → numeric geo target ID | |
| Lint locally, then | |
| ✓ | Commit a previewed spec atomically, always paused |
| Account inventory | |
| Full read-back: settings, targeting, ad groups, keywords, RSA assets, ad strength |
create_campaign requires a confirm_token that only a successful
preview_campaign in the same process can mint. Campaign status is hardcoded to
PAUSED with no parameter to override it, and the whole campaign goes in one
mutation with partial_failure off — there is no path to a half-built campaign
that sits invisible until it spends.
Analyse
Tool | Purpose |
| Metrics by campaign, ad group, or ad. |
| Per-keyword metrics with quality score, sorted by cost. |
| The real queries that triggered ads, with added/excluded status. |
| Structural review — targeting gaps, Display expansion left on, thin RSAs, poor ad strength, all-broad-match ad groups, keywords duplicated across ad groups, budget-to-bid mismatch. Uses no performance data, so it works on test accounts and brand-new campaigns |
| Every conversion action with status, goal role, counting type, lookback window, and how many conversions it actually recorded. Diagnoses whether a low conversion count is a weak funnel or a broken tag |
| Who changed what, when, and from which client (web UI, Editor, API, scripts), with the fields that changed. Google retains 30 days |
| Credentials, API version, and which API methods your token's access level permits. Run this first when something is refused |
| Arbitrary read-only GAQL. Only |
check_setup probes each method with a real call — the one write probe uses
validate_only and writes nothing. It is the fastest way to find out whether you
are on Test, Explorer, or Basic access.
conversion_actions judges bidding inclusion from primary_for_goal, not the
legacy include_in_conversions_metric field. Goal-based accounts ignore the
legacy flag: verified on a live account where an action with
include_in_conversions_metric=false still counted toward metrics.conversions.
Keyword Planner
All four KeywordPlanIdeaService methods. All require Basic access.
Tool | Purpose |
| Expand seed terms and/or a URL into ideas with volume, competition, and bid range |
| Volumes for a keyword list you already have. Reports on exactly what you pass; does not expand |
| Projected clicks, cost, CPC, conversions, and CPA at a given budget and bid. Run before |
| Distribute keywords across ad groups that already exist |
assign_ad_groups assigns and refines; it does not invent themes. Google's
generate_ad_group_themes takes resource names of existing ad groups, so to
split one oversized ad group you must create the themed ad groups first, then
run this to distribute keywords into them.
Act
Tool | Purpose |
| What is autonomous, propose-only, or forbidden; the evidence thresholds; remaining change budget; whether the trust switch is on |
| Turn Display expansion and/or search partners off. A request to enable one is refused |
| Campaign-level negatives, per-term evidence measured server-side |
| Pause keywords spending without converting |
| Same for whole ad groups, at higher thresholds |
| Undo a run from the journal's stored inverses |
All of these accept dry_run: true, which validates against the live API and
journals the intent without writing. Dry-run entries do not consume change
budget.
Remove
Tool | Purpose |
| Permanently remove a campaign. Irreversible |
Three gates, all required together:
A
confirm_tokenminted only by the tool's own preview, salted per process and bound to the specific account, campaign ID, and name.acknowledge_irreversible: true.The campaign must already be
PAUSED(require_pause_before_removal), which makes removal two deliberate steps separated in time rather than one destructive one.
Called with no token it previews: name, status, budget, how many ad groups, keywords, and ads go with it, and recent spend and conversions. Historical stats remain queryable after removal, so reporting data is not lost — but the campaign can never be re-enabled.
Configuration reference
Environment variables
Variable | Default | Purpose |
|
| Credentials file |
|
| Policy file |
|
| Audit journal |
|
| State directory used by |
|
| Refuse to create a campaign above this daily budget |
| unset | Comma-separated allowlist. Unset means any accessible account |
Policy file
See policy.yaml.example, which documents every key. The
main sections:
actions— the tier for each actionevidence— volume floors, conversion caps, and CPA ceilings per actionchange_budget— per-run and per-week caps, per-entity cooldowncircuit_breaker— spend-spike multiple, learning-period lengthconversion_tracking_ok/require_conversion_tracking— the trust switchrequire_pause_before_removal— gate on campaign removal
Developer token access tiers
Access level gates methods, not just which accounts you can reach. Verified behaviour on Explorer access:
Works on Explorer | Requires Basic |
All GAQL reporting ( | Every Keyword Planner tool |
| |
|
Explorer access reaches production data, so reporting working against your
real account is not evidence that Basic access was approved. The Keyword
Planner tools return authorization_error.DEVELOPER_TOKEN_NOT_APPROVED — "not
allowed for use with explorer access" — until Basic is granted. Check the level
in your manager account's API Center rather than inferring it from a successful
query.
Test accounts, which a Test-access token is limited to, must live under a separate test manager account, not your production manager account.
Limitations
Search campaigns only. No Performance Max, Display, Video, Shopping, or Demand Gen. Performance Max in particular uses asset groups rather than ad groups, so it is a separate build rather than a flag.
No enabling or unpausing, ever. Forbidden outright, not gated. Bringing a campaign live is a human action in the Google Ads UI.
No budget or bid changes. Propose-only: the agent writes up the case and the numbers, you decide.
Auction insights is not available. The Google Ads API does not expose it in any version.
Committed writes are lightly exercised. The mutation path validates cleanly against the live API, but has seen limited real-world use. Start on a test account and use
dry_run: true.The bundled scheduler is macOS-only. Use cron elsewhere.
Autonomous mode requires the Claude Code CLI. The MCP server itself is client-agnostic.
Development
./.venv/bin/python -m pytest -q90 offline tests: campaign spec linting, temp-ID wiring, the always-paused invariant, bidding-strategy oneofs, RSA assembly, GAQL query building, the read-only guard, every audit rule, and the whole policy engine — tiering, fail-closed behaviour, evidence floors, the CPA override, change budget, cooldowns, circuit breaker, and journal accounting.
Layout
ads_agent/
client.py lazy API client, readable error formatting
spec.py campaign spec, local lint, confirm tokens
build.py spec → atomic mutate operations
report.py GAQL query building, table formatting, audit rules
policy.py the policy engine
journal.py append-only audit log
server.py MCP tool definitions
agent/
prompt.md standing instructions for autonomous runs
run.sh one scheduled run
install-schedule.sh generates and installs the launchd job
scripts/
get_refresh_token.py OAuth desktop flowNotes for contributors
Never build an update mask with
google.api_core.protobuf_helpers.field_mask(). It diffs by value, so setting a boolean toFalseproduces a mask that omits the field and the API silently ignores the change. Every spend-reducing network change sets a boolean toFalse, so that helper turnsset_networksinto a no-op that reports success. Use explicit mask paths.Verify field names against the installed library, not from memory. The API drifts; for example v25 uses
campaign.start_date_timein"yyyy-MM-dd HH:mm:ss"format, not the olderstart_date.Raise
ToolError(frommcp.server.mcpserver.exceptions) for anything whose message the model needs to read. Any other exception has its text replaced with a generic "Error executing tool", which silently discards the API's validation messages.Evidence must be measured server-side. If a future change lets a tool caller pass in the statistics that justify an action, the policy engine stops being a safety mechanism.
The API version is pinned in
ads_agent/client.py(API_VERSION).
License
MIT. Set the copyright holder in LICENSE before publishing.
See the disclaimer above. This software is provided without warranty, and you are solely responsible for what it does in your advertising accounts.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/lucagalvani/google-ads-agent'
If you have feedback or need assistance with the MCP directory API, please join our Discord server