Metadata-Version: 2.4
Name: adsmith
Version: 0.1.0
Summary: Google Ads MCP server that can actually change your ads — with a diff, a dry-run, and an undo.
Project-URL: Homepage, https://github.com/sujayrittikar/adsmith
Project-URL: Issues, https://github.com/sujayrittikar/adsmith/issues
Author: Sujay Rittikar
License: MIT
License-File: LICENSE
Keywords: advertising,claude,google-ads,mcp,model-context-protocol,ppc
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial
Requires-Python: >=3.12
Requires-Dist: fastmcp>=2.0
Requires-Dist: pydantic>=2.7
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Provides-Extra: live
Requires-Dist: google-ads>=27.0; extra == 'live'
Description-Content-Type: text/markdown

<h1 align="center">adsmith</h1>

<p align="center">
  <strong>Let Claude actually run your Google Ads — with a diff, a dry-run, and an undo before anything touches your budget.</strong>
</p>

<p align="center">
  <a href="https://github.com/sujayrittikar/adsmith/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/sujayrittikar/adsmith/actions/workflows/ci.yml/badge.svg"></a>
  <a href="https://pypi.org/project/adsmith/"><img alt="PyPI" src="https://img.shields.io/pypi/v/adsmith"></a>
  <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-MIT-blue"></a>
  <img alt="Google Ads API" src="https://img.shields.io/badge/Google%20Ads%20API-v25-4285F4">
</p>

A **Google Ads MCP server** that does more than read. It finds your wasted spend,
your budget-capped winners, and your weak ad copy — then proposes the fix as a
reviewable diff that you approve before a single cent moves.

> Google's [official Google Ads MCP server](https://github.com/googleads/google-ads-mcp)
> is read-only: three tools, no mutations. adsmith is the other half — the part
> that changes things, wrapped in the safety machinery that makes letting an LLM
> near an ad budget a reasonable idea.

---

## Try it in 30 seconds — no Google account needed

```bash
uvx adsmith --demo
```

That's it. No OAuth, no developer token, no waiting on Google's approval queue.
`--demo` boots a synthetic ad account with realistically messy data, and **every
tool works against it**, including proposing, applying, and rolling back changes.

Wire it into Claude Code:

```bash
claude mcp add adsmith -- uvx adsmith --demo
```

Then ask: *"where am I wasting money?"*

<!-- Record with: ./scripts/record_demo.sh  (needs vhs + ttyd + ffmpeg)
     Then swap the code block below for: ![demo](assets/demo.gif) -->

```
> where am I wasting money?

  Pulling the search-terms report for Northwind Outdoor Gear...

  $6,274.92 of your $14,525 spend over the last 30 days went to search terms
  that produced zero conversions — about 43%. The pattern is clear:

  | search term                    | 30-day cost | campaign               |
  |--------------------------------|-------------|------------------------|
  | free hiking boots              |     $261.98 | Search - Hiking Boots  |
  | hiking boots repair near me    |     $238.70 | Search - Hiking Boots  |
  | northwind outdoor gear careers |     $231.27 | Brand - Northwind      |
  | how to clean hiking boots      |     $215.41 | Search - Hiking Boots  |
  | used camping gear cheap        |     $191.82 | Search - Generic Outdoor |

  Three intent groups worth blocking: job seekers, repair/rental, and
  free-seekers. Want me to propose negatives?

> yes

  Change-set cs_1a5956d1 (pending) on customer 1234567890
  Rationale: Non-commercial intent

    1. Negative keyword on Search - Generic Outdoor: + keyword=jobs
    2. Negative keyword on Search - Generic Outdoor: + keyword=rental
    3. Negative keyword on Search - Generic Outdoor: + keyword=diy
    4. Negative keyword on Search - Generic Outdoor: + keyword=wikipedia
    5. Negative keyword on Search - Generic Outdoor: + keyword=donation

  Estimated daily spend decrease: 19.92
  Estimated 30-day impact: 597.60

  Expires: 2026-08-07T18:06:21+00:00
  To apply: apply_changeset(changeset_id='cs_1a5956d1', confirm=True)

  Nothing has been applied. Note I left "careers" alone — that spend is on your
  brand campaign, which your guardrails protect.
```

Nothing was applied. That's the point.

---

## Safety, up front

The honest first reaction to "AI edits my ad account" is *no*. So here is exactly
what stops it going wrong.

**Every mutation takes the same path.** There is no other route to the API:

```
propose_*  →  guardrails  →  validate_only dry-run  →  apply_changeset(confirm=True)
                   ↓                    ↓
              BLOCK is final     Google validates without committing
```

**Guardrails live in a file on disk, outside the model's reach.** No tool can
relax one. A `BLOCK` is terminal — the assistant can't argue past it, because the
check is Python reading YAML, not a suggestion in a prompt.

```yaml
budgets:
  max_increase_pct_per_day: 20        # no 10x raises from a stray decimal
  max_decrease_pct_per_day: 30
  account_daily_spend_ceiling: null   # set this before automating anything

bids:
  max_target_cpa_change_pct: 15       # big swings reset Smart Bidding's learning

pausing:
  never_pause_if_conversions_last_30d: 5   # conversions lag; "dead" often isn't
  max_keywords_paused_per_changeset: 50
  allow_pausing_campaigns: false

protected:
  campaign_ids: []
  campaign_name_patterns: ["*Brand*"] # brand campaigns are cheap and high-ROAS;
                                      # "optimizing" them is nearly always a loss
```

Ask for something out of bounds and you get this, not a workaround:

```
Guardrails:
  - [BLOCK] budgets.max_increase_pct_per_day (change 1): Raising the budget for
    Search - Hiking Boots from 80.00 to 800.00 is +900.0%, above the 20% daily cap.

BLOCKED by guardrails — this change-set cannot be applied. The [BLOCK] entries
above are final.
These limits live in ~/.config/adsmith/guardrails.yaml. I cannot override them;
editing that file is the only way to change them.
```

**The rest of the safety model:**

- **Approval is explicit.** `apply_changeset` without `confirm=True` returns the
  diff and changes nothing.
- **Dry run first.** Every apply is validated through the API's `validate_only`
  mode before it commits.
- **Undo.** Change-sets carry pre-built inverse operations. Where something
  genuinely can't be reversed (creating an ad), it says so plainly instead of
  implying a clean undo.
- **Proposals go stale.** A change-set built on last week's numbers expires
  rather than applying against an account that has moved.
- **Guardrails re-run at apply time**, not just at propose time — so tightening
  the policy invalidates a proposal that was already sitting in the queue.
- **Everything is logged.** `audit_history` answers "what changed last Tuesday?"
  with every proposal, verdict, dry-run, application, and rollback.

Full detail in [SECURITY.md](SECURITY.md).

---

## Playbooks

Five slash commands, each a written analysis procedure rather than a black box.
They're [plain prose you can read and edit](src/adsmith/prompts/__init__.py) —
including the statistical guardrails that stop an eager assistant acting on noise.

| Command | What it does |
|---|---|
| `/wasted-spend` | Mines search terms, groups them by *intent* (job seekers, DIY, free-seekers, repair), proposes negatives with a projected saving |
| `/budget-check` | Finds campaigns losing impression share to budget *while hitting their CPA target*, proposes a zero-sum reallocation |
| `/bid-tuning` | Compares actual CPA/ROAS to targets, proposes bounded steps — and refuses to act on fewer than 30 conversions |
| `/ad-audit` | Finds poor-strength RSAs, too-few headlines, over-pinned assets; proposes replacements that vary the *angle*, not the wording |
| `/weekly-report` | Digest with anomaly detection — and it checks whether a conversion cliff is a tracking break before calling it a performance drop |

---

## Tools

<details>
<summary><strong>Reporting (9)</strong> — curated reports, not a raw GAQL passthrough</summary>

| Tool | Returns |
|---|---|
| `list_accounts` | Every reachable account with currency and timezone |
| `account_overview` | Spend, conversions, CPA, ROAS vs. the preceding period |
| `campaign_performance` | Per-campaign metrics + impression share + budget |
| `ad_group_performance` | Per-ad-group metrics |
| `keyword_performance` | Per-keyword metrics with quality score |
| `search_terms` | What people typed, with wasted spend totalled |
| `ad_performance` | Ads plus a precomputed `weak_ads` diagnosis |
| `budget_pacing` | MTD spend, month-end projection, budget-capped campaigns |
| `run_gaql` | Read-only escape hatch — SELECT only |

Reports pre-aggregate totals and cap rows by default. When a report truncates it
says so, and the totals still cover every row — a capped report never understates
account spend.

</details>

<details>
<summary><strong>Mutations (11)</strong> — all routed through change-sets</summary>

| Tool | Notes |
|---|---|
| `propose_negative_keywords` | Defaults to PHRASE; BROAD negatives block converting traffic too often |
| `propose_budget_change` | Takes a whole reallocation map as one reviewable change-set |
| `propose_bid_adjustment` | tCPA/tROAS only; refuses to switch bidding strategy |
| `propose_pause` | Keywords, ads, campaigns |
| `propose_rsa` | Validates Google's 30/90-character limits before proposing |
| `preview_changeset` | Diff + `validate_only` dry run |
| `apply_changeset` | Requires `confirm=True` |
| `rollback_changeset` | Replays stored inverses |
| `list_changesets` | Recent proposals and their status |
| `audit_history` | The full record |
| `show_guardrails` | Active policy and where it's loaded from |

</details>

---

## Running it unattended

`adsmith scan` does the analysis headlessly — point cron at it and the work
happens while you sleep.

```bash
adsmith scan --demo              # digest of everything worth your attention
adsmith scan --propose           # ...and queue change-sets for the clear-cut fixes
```

```
9 finding(s), roughly 9,157.94 per 30 days at stake.

1. [WARNING] Search - Generic Outdoor: 2,384.42 on non-converting search terms
   85 search term(s) spent money over 30 days with no conversions.
   Queued: cs_ace79e8b (pending your approval)

3. [WARNING] Search - Camping Gear: CPA is +105% against target
   Actual CPA 61.41 vs target 30.00 over 61 conversions.

5. [WARNING] Search - Hiking Boots is capped and beating its target
   Losing 38% of impressions to budget on 80.00/day, at a CPA of 38.00
   against a 45.00 target.
```

**It never applies anything** — `--propose` queues change-sets in `pending`, and
guardrails apply in full with nobody watching. The scan is deterministic Python,
not an LLM: it costs nothing per run, gives identical output for identical input,
and can't hallucinate a campaign ID. The judgement calls stay in the playbooks
where you're present to read them.

Exit codes are cron-shaped (`0` clean, `1` error, `2` findings), so
`adsmith scan || notify-me` just works. Full setup, including systemd timers, in
[docs/SCHEDULING.md](docs/SCHEDULING.md).

## Read-only mode

```bash
adsmith --read-only
```

Unregisters every mutation tool, so the server *physically cannot* write —
removal, not refusal. A good way to spend a first week on a real account.

## Connecting a real account

`--demo` needs nothing. A real account needs three things from Google, and the
developer token is the slow one — Google's review queue runs days to weeks, and
a fresh token only works against *test* accounts until Basic access is approved.

**Start the token application on day one, then keep using `--demo` while you
wait.** Full walkthrough in [docs/SETUP.md](docs/SETUP.md).

```bash
pip install 'adsmith[live]'
adsmith auth                    # one-time OAuth; writes ~/.config/adsmith/credentials.json (0600)
adsmith init-config             # writes ~/.config/adsmith/guardrails.yaml — edit it
claude mcp add adsmith -- adsmith
```

Set `account_daily_spend_ceiling` in your guardrails before you do anything else.

---

## How this differs from Google's official server

Both are open source and both are Python. They solve different halves.

| | [googleads/google-ads-mcp](https://github.com/googleads/google-ads-mcp) | **adsmith** |
|---|---|---|
| Reads | Raw GAQL passthrough | 9 curated reports + a GAQL escape hatch |
| Writes | — | 5 proposal tools behind change-set review |
| Guardrails | — | Policy file the model can't override |
| Undo | — | Inverse operations + audit log |
| Playbooks | — | 5 slash commands |
| Unattended scanning | — | `adsmith scan` (cron-shaped exit codes) |
| Read-only mode | Always read-only | `--read-only` opt-in |
| Try without a dev token | — | `uvx adsmith --demo` |
| Telemetry | Usage headers on API calls | None |
| Maintained by | Google | This repo |

Use theirs if you want a thin, official, read-only wrapper. Use adsmith if you
want the thing to actually improve the account.

---

## Contributing

You don't need a Google Ads account to contribute — the whole test suite runs
against the demo backend. See [CONTRIBUTING.md](CONTRIBUTING.md).

The one rule: **nothing reaches the API except through a change-set.** A tool
that calls `mutate` directly won't be merged, however convenient.

## Status

Alpha, and honest about it. 102 tests, CI on 3.12/3.13.

The safety layer is thoroughly covered. The live Google API path is tested
against real protobuf types — message construction, enums, field masks, error
translation — with the network stubbed, but it has never run against Google's
actual servers. That's the part most likely to surprise you.

If you connect a real account: start with `--read-only` for a week, then tight
guardrails and one small change. `docs/SETUP.md` §7 has the round trip worth
doing first.

## Licence

MIT.
