Skip to main content
Glama
lucagalvani

google-ads-agent

by lucagalvani
README.md
# 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](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](https://developers.google.com/google-ads/api/terms),
  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](#what-it-does)
- [The safety model](#the-safety-model)
- [Full API coverage and the permission table](#full-api-coverage-and-the-permission-table)
- [Requirements](#requirements)
- [Install](#install)
- [Configure credentials](#configure-credentials)
- [Use it interactively](#use-it-interactively)
- [Run it autonomously](#run-it-autonomously)
- [Tool reference](#tool-reference)
- [Configuration reference](#configuration-reference)
- [Developer token access tiers](#developer-token-access-tiers)
- [Limitations](#limitations)
- [Development](#development)
- [License](#license)

---

## 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** | `add_negative_keywords`, `set_networks` (off only), `pause_keywords`, `pause_ad_groups` | 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:

1. **The server measures the evidence; it never accepts it from the caller.** An
   agent that supplies its own justification can fabricate it.
   `add_negative_keywords` queries the search-term data itself; `set_networks`
   queries the network split itself.
2. **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.
3. **Conversions protect an entity, but not at any price.** A `max_conversions:
   0` rule alone lets a single token conversion shield unlimited waste, so
   `max_cost_per_conversion` overrides that protection.
4. **Pause, never remove.** Every autonomous action stores its inverse in the
   journal, so `revert_last_run` can undo an entire run.
5. **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.
6. **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, and
   `check_setup` reports 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 |
|---|---|
| `autonomous` | Runs unattended, once the server has measured the evidence. Refused if it names an evidence rule the server cannot measure for that resource. |
| `confirm` | Runs when a human approves the exact diff in-session. Unreachable in an unattended run. |
| `propose` | Never executed. Written up for a person to do. |
| `forbidden` | 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.

```bash
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 seed
```

`grant` 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](#developer-token-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](https://claude.com/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

```bash
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 needed
```

The 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

```bash
./.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](https://myaccount.google.com/permissions) and
retry; Google returns a refresh token only on first consent.

<details>
<summary>Using environment variables instead of a config file</summary>

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.

</details>

---

## Use it interactively

Register the MCP server with your client. For Claude Code:

```bash
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

```bash
mkdir -p ~/.ads-agent
cp policy.yaml.example ~/.ads-agent/policy.yaml
```

Edit 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

```bash
./agent/run.sh
```

Read the report it writes to `~/.ads-agent/reports/`. Do this before scheduling
anything.

### 3. Schedule it

```bash
./agent/install-schedule.sh          # daily at 07:15
./agent/install-schedule.sh 6 30     # daily at 06:30
./agent/install-schedule.sh --uninstall
```

Paths 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:

```cron
15 7 * * * /path/to/google-ads-agent/agent/run.sh
```

### What a run produces

| Path | Contents |
|---|---|
| `~/.ads-agent/reports/` | One dated Markdown report per run: actions taken, proposals, blocked attempts, data-quality notes |
| `~/.ads-agent/logs/` | Full transcript per run |
| `~/.ads-agent/journal.jsonl` | 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 1234567890
```

It 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 |
|---|---|---|
| `list_accounts` | | Accessible accounts with currency, time zone, manager/test flags |
| `suggest_geo_targets` | | Location name → numeric geo target ID |
| `preview_campaign` | | Lint locally, then `validate_only` against the API. Returns the plan and a confirm token |
| `create_campaign` | ✓ | Commit a previewed spec atomically, always **paused** |
| `list_campaigns` | | Account inventory |
| `get_campaign` | | 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 |
|---|---|
| `performance` | Metrics by campaign, ad group, or ad. `segment` breaks it down by network, device, month, or date. Campaign level includes impression share and whether it is lost to budget or to rank |
| `keyword_performance` | Per-keyword metrics with quality score, sorted by cost. `zero_conversions_only` isolates spend with nothing to show for it |
| `search_terms` | The real queries that triggered ads, with added/excluded status. `only_unadded` surfaces negative-keyword candidates |
| `audit_campaign` | 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 |
| `conversion_actions` | 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 |
| `change_history` | Who changed what, when, and from which client (web UI, Editor, API, scripts), with the fields that changed. Google retains 30 days |
| `check_setup` | Credentials, API version, and which API methods your token's access level permits. Run this first when something is refused |
| `run_gaql` | Arbitrary read-only GAQL. Only `SELECT` is accepted |

`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 |
|---|---|
| `suggest_keywords` | Expand seed terms and/or a URL into ideas with volume, competition, and bid range |
| `keyword_volumes` | Volumes for a keyword list you already have. Reports on exactly what you pass; does not expand |
| `forecast_campaign` | Projected clicks, cost, CPC, conversions, and CPA at a given budget and bid. Run before `preview_campaign` |
| `assign_ad_groups` | 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 |
|---|---|
| `policy_status` | What is autonomous, propose-only, or forbidden; the evidence thresholds; remaining change budget; whether the trust switch is on |
| `set_networks` | Turn Display expansion and/or search partners **off**. A request to enable one is refused |
| `add_negative_keywords` | Campaign-level negatives, per-term evidence measured server-side |
| `pause_keywords` | Pause keywords spending without converting |
| `pause_ad_groups` | Same for whole ad groups, at higher thresholds |
| `revert_last_run` | 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 |
|---|---|
| `remove_campaign` | Permanently remove a campaign. **Irreversible** |

Three gates, all required together:

1. A `confirm_token` minted only by the tool's own preview, salted per process
   and bound to the specific account, campaign ID, and name.
2. `acknowledge_irreversible: true`.
3. 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 |
|---|---|---|
| `GOOGLE_ADS_CONFIGURATION_FILE_PATH` | `~/google-ads.yaml` | Credentials file |
| `ADS_AGENT_POLICY` | `~/.ads-agent/policy.yaml` | Policy file |
| `ADS_AGENT_JOURNAL` | `~/.ads-agent/journal.jsonl` | Audit journal |
| `ADS_AGENT_STATE` | `~/.ads-agent` | State directory used by `agent/run.sh` |
| `ADS_AGENT_MAX_DAILY_BUDGET` | `100` | Refuse to create a campaign above this daily budget |
| `ADS_AGENT_ALLOWED_CUSTOMER_IDS` | unset | Comma-separated allowlist. Unset means any accessible account |

### Policy file

See [`policy.yaml.example`](policy.yaml.example), which documents every key. The
main sections:

- `actions` — the tier for each action
- `evidence` — volume floors, conversion caps, and CPA ceilings per action
- `change_budget` — per-run and per-week caps, per-entity cooldown
- `circuit_breaker` — spend-spike multiple, learning-period length
- `conversion_tracking_ok` / `require_conversion_tracking` — the trust switch
- `require_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 (`performance`, `keyword_performance`, `search_terms`, `audit_campaign`, `run_gaql`, `list_*`, `get_campaign`) | Every Keyword Planner tool |
| `suggest_geo_targets` | |
| `mutate` with `validate_only` | |

**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

```bash
./.venv/bin/python -m pytest -q
```

90 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 flow
```

### Notes for contributors

- **Never build an update mask with
  `google.api_core.protobuf_helpers.field_mask()`.** It diffs by *value*, so
  setting a boolean to `False` produces a mask that omits the field and the API
  silently ignores the change. Every spend-reducing network change sets a
  boolean to `False`, so that helper turns `set_networks` into 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_time` in
  `"yyyy-MM-dd HH:mm:ss"` format, not the older `start_date`.
- **Raise `ToolError`** (from `mcp.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](LICENSE). Set the copyright holder in `LICENSE` before publishing.

See the [disclaimer](#️-disclaimer--read-before-using) above. This software is
provided without warranty, and you are solely responsible for what it does in
your advertising accounts.

TDQS

A3.7/5.0

Scored across 41 tools

Disambiguation4/5

Most tools target a distinct resource or action, and the descriptions clearly separate pairs like pause_keywords vs pause_ad_groups and add_negative_keywords vs add_shared_negative_keywords. However, the four performance-variant tools, the preview_campaign/forecast_campaign pair, and the governance tools (policy_status, list_capabilities, explain_capability) require careful reading to pick correctly, and the generic mutate/run_gaql escape hatches broadly overlap with the specific tools.

Naming Consistency4/5

Almost all tool names are snake_case with an imperative verb first, following a list_/create_/preview_/pause_/remove_/promote_ pattern. The main deviations are the noun-style report tools like keyword_performance, ad_asset_performance, geo_performance, search_terms, and conversion_actions, which are consistent in style but not verb_noun. Overall the naming is predictable and readable.

Tool Count2/5

At 41 tools, this is well over the 25-tool threshold and makes for a heavy selection surface that will consume significant agent context. The broad Google Ads scope justifies much of the breadth, but clusters like the four performance reports, the capability/proposal machinery, and the generic mutate path could be consolidated. It is capable but oversized.

Completeness4/5

The toolset covers the campaign lifecycle, keyword/ad group/ad operations, negative keywords, drafts, audits, forecasting, performance analysis, and governance, with mutate and run_gaql as escape hatches so there are no dead ends. A few common operations such as pausing/toggling campaigns or creating ad groups are not first-class tools and must go through mutate, and shared-list creation is left to raw API calls. These are workable gaps rather than blocking ones.

Maintenance

ActivityMaintained
ResponsivenessNo issues