# AdRails (adrails.dev) > AdRails is an open-source MCP server that lets AI agents operate Google Ads inside caps you set, on your own machine, with your own keys. Version: 0.2.2 · Website: https://adrails.dev · Source: https://github.com/antonioac1/adrails (Apache-2.0) Python package and module: `adsops` — that is what you install and run (`adsops-mcp`, or `python3 -m adsops`). This file is the long-form reference: install, configuration, every MCP tool with its arguments, the change workflow, the guard, and the limits. Short version for agents: https://adrails.dev/llms.txt --- ## Summary (from llms.txt) AdRails is a local MCP server (JSON-RPC 2.0 over stdio) for Google Ads. It is Apache-2.0 and free to run. The Python package and module are named `adsops` — that is what you install and run (`adsops-mcp`, or `python3 -m adsops`). An agent may read campaigns, status and search terms as often as it likes. Every write must land in a reviewed desired-state file on disk, get diffed against live state, and clear caps that live in a policy file no AdRails tool writes. AdRails (adrails.dev) is guardrails for AI agents running your ads. It runs on your machine against your own Google Cloud project and Google Ads credentials. Nothing is hosted: the only endpoint the package talks to is the Google Ads API, and credentials are read from the sources you configure (a service-account JSON file, a static token, or a secret-manager lookup). Optional hosted add-ons are Lightning-first and metered per call in sats — coming soon; nothing in this release contacts any hosted service. --- ## Install The core is stdlib-only. The PyPI release is coming soon; install from GitHub: ```bash # from GitHub (PyPI release coming soon) pipx install git+https://github.com/antonioac1/adrails # console script: adsops-mcp # or run without installing: uvx --from git+https://github.com/antonioac1/adrails adsops-mcp --config /absolute/path/config.json ``` No other dependencies. Service-account tokens are signed with your system `openssl` when `google-auth` isn't installed (install `google-auth` if you prefer it). Check it offline (everything mocked, no network): ```bash python3 -m adsops --selftest # or: deploy/smoke_docker.sh ``` The repository also ships adversarial QA suites that run against simulated accounts: ```bash python3 -m adsops --selftest # 430 unit checks, everything mocked python3 tests/qa_invariants.py # 200 random sequences against a simulated Google Ads account python3 tests/qa_inputs.py # hostile policy/state/config inputs (NaN, Infinity, bad dates, bad UTF-8, ...) python3 tests/qa_secrets.py # canary tokens injected everywhere; none may reach output or logs ``` --- ## Configuration Every account, credential source and path lives in one JSON config file; paths are `~`-expanded at load time. ```json { "tenant": "your-tenant", "meter_log": "~/.adsops/meter.jsonl", "audit_log": "~/.adsops/audit.jsonl", "plan_ledger": "~/.adsops/plans.jsonl", "lock_dir": "~/.adsops/locks", "guard_heartbeat": "~/.adsops/guard.heartbeat", "credentials": {"google_ads": {"type": "service_account", "sources": [{"kind": "file", "path": "~/.adsops/sa.json"}]}}, "google_ads": {"api_version": "v24", "end_field": "end_date_time", "login_customer_id": null}, "commit_policy": {"require_clean_git": true, "allow_commit": true, "allow_set": true}, "accounts": [{"key": "main", "platform": "google_ads", "customer_id": "000-000-0000", "campaigns": [{"key": "search_core", "state_file": "~/ads/search_core.json", "policy_file": "~/ads/search_core.policy.json"}]}] } ``` `commit_policy` defaults to `require_clean_git: true` and `allow_commit: false`, so commits and `adsops_set` stay off until you turn them on. Credential sources are the ones you configure: a service-account JSON file, a static token, or a secret-manager lookup. The offline test suite runs entirely off a static token config: ```json "credentials": {"google_ads": {"type": "static", "token": "x"}} ``` ### Register the server Diagnostics go to stderr; the server speaks JSON-RPC 2.0 over stdio, one object per line. ```json { "mcpServers": { "adrails": { "command": "python3", "args": ["-m", "adsops", "--config", "/absolute/path/config.json"] } } } ``` ### Schedule the guard A chat session cannot watch spend, so the pause-only spend check runs from your OS scheduler every 15 minutes. Templates for launchd, systemd and cron live in `deploy/`; render the placeholders (`__PYTHON__`, `__CONFIG__`, `__PKG__`, `__LOGDIR__`) and read `deploy/GUARD.md` before installing: ```bash PY=$(command -v python3); CFG=/absolute/path/config.json PKG=/absolute/dir/containing/adsops; LOGDIR="$HOME/.adsops/logs" mkdir -p "$LOGDIR" sed -e "s|__PYTHON__|$PY|g" -e "s|__CONFIG__|$CFG|g" \ -e "s|__PKG__|$PKG|g" -e "s|__LOGDIR__|$LOGDIR|g" \ deploy/launchd/com.adsops.guard.plist.template \ > ~/Library/LaunchAgents/com.adsops.guard.plist plutil -lint ~/Library/LaunchAgents/com.adsops.guard.plist launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.adsops.guard.plist ``` The command all three units run is `PYTHONPATH=$PKG $PY -m adsops --config $CFG guard-all`. --- ## Tools | Tool | What it does | | --- | --- | | `adsops_campaigns` | Lists the campaigns this deployment may operate: keys, accounts, state files, and the active commit policy. | | `adsops_status` | Live read for one campaign: status, end date, daily budget, spend today and lifetime, negative count, whether caps come from the policy file, and the guard heartbeat's age. | | `adsops_terms` | Clicked search terms for the last N days (1–90, default 14): term, matched keyword, clicks, cost. | | `adsops_plan` | Pure diff of the desired-state file against live state; writes nothing. Returns the operations, notes, `ops_count`, the exposure increases it found, and a single-use `plan_id` with its expiry. | | `adsops_apply` | Validate-only unless `commit=true`. A commit needs `commit_policy.allow_commit`, a clean git-tracked state file, a fresh guard heartbeat when the change adds exposure, and the `plan_id` from `adsops_plan` when the policy requires it. | | `adsops_set` | Proposes a budget / status / end-date / negatives change with a short `reason`; checks it against policy caps and live state, writes the state file and commits it to git. `dry_run=true` returns a `plan_id` instead of writing; `apply=true` needs that `plan_id` when the policy requires it. | | `adsops_guard` | Reads spend and pauses the campaign if the daily or lifetime threshold is crossed. Pause only: it never enables, raises a budget or edits keywords. Validate-only unless `commit=true`. | | `adsops_usage` | Metering summary for the tenant: calls, errors, units, ops and median latency per tool. | ### Arguments - `adsops_campaigns` — no arguments. Returns the configured campaign keys, their accounts and state files, plus the active commit policy. Only configured campaigns are enumerable. - `adsops_status` — the campaign key. Returns status, end date, daily budget, spend today and lifetime, negative count, `caps_from` (`policy` when the policy file governs the caps, with a warning when the state file's caps disagree), and the guard heartbeat's age. - `adsops_terms` — the campaign key and `days` (1–90, default 14). A value outside the range is an error. Returns term, matched keyword, clicks and cost. - `adsops_plan` — the campaign key. Pure read: it writes nothing, and repeated calls over an unchanged state file return the same operations. Returns `ops`, notes, `ops_count`, `exposure_increases` (for example `"budget up 8.00 -> 10.00"`), and a single-use `plan_id` with its expiry (default TTL 900 s, from `plan_ttl_s`). - `adsops_apply` — the campaign key and `commit` (default `false`). Without `commit` it performs a validate-only pass against Google and lands nothing. With `commit=true` it requires `commit_policy.allow_commit`, a clean git-tracked state file, a fresh guard heartbeat when the plan adds exposure, and a `plan_id` when `require_plan_id` is on. A mismatched, reused or expired `plan_id` is refused. - `adsops_set` — the campaign key, the change (budget, status, end date, negatives), a short `reason`, and optionally `dry_run=true` (returns a `plan_id` instead of writing) or `apply=true` (which needs that `plan_id` when the policy requires it). A missing `reason` is an error. Caps are never writable here: `guards` and `allow_enable` are refused outright. - `adsops_guard` — the campaign key and `commit` (default `false`). Reads spend and returns an action of `PAUSE` or `none`; validate-only unless `commit=true`. Its only mutate service is `campaigns` — it never touches budgets or keywords. - `adsops_usage` — no arguments. Metering summary for the tenant per tool: calls, errors, units, ops and median latency. ### Behaviour examples (from the offline test suite) - Plan → apply, no commit: `adsops_plan` on a state file with budget 10 against a live budget of 8 returns a `campaignBudgets` op and `ops_count == 1` (the desired negative already exists live); `adsops_apply` without `commit` records a single validate-only mutate and returns `committed: false`. - A committing apply records validate-then-commit, in that order, so nothing lands if the validate pass fails. - With `require_plan_id` on: apply without an id → `"plan_id required"`; the wrong id → `"does not match"`; a replayed id → `"already used"`; an id past `plan_ttl_s` → `"expired"`. - With `allow_commit: false`, `adsops_apply` with `commit=true` is an error; `adsops_set` before `allow_set` is turned on is an error naming `allow_set`. - A dirty state file (`git status` shows a modification) blocks both `adsops_apply` and `adsops_set` — the change must be committed and unchanged. - A budget of 30 against a guard band topping out at 25 is refused with `"outside guard range"`; so is 22 when the policy file caps the budget at 20 even though the state file says 25. --- ## The change workflow: desired state → plan → validate-only apply → commit → status 1. **Write the desired state.** The agent proposes a change by editing the campaign's desired-state JSON file on disk (budget, status, end date, negatives). Keep these files in git: that is what makes "reviewed desired state" real — `git diff` is the change review. 2. **Plan.** `adsops_plan` computes the pure diff of the desired-state file against live state and writes nothing. It returns the operations, notes, `ops_count`, the exposure increases, and a single-use `plan_id` with its expiry. 3. **Validate.** `adsops_apply` without `commit` sends the identical payload to Google in validate-only mode. Nothing lands if that fails. 4. **Commit.** `adsops_apply` with `commit=true` needs `commit_policy.allow_commit`, a clean git-tracked state file, a fresh guard heartbeat when the change adds spend exposure, and the `plan_id` from the plan when the policy requires it. The commit is preceded by the same validate-only pass. If Google times out mid-commit, the failure is reconciled, never blind-retried: AdRails re-reads the account and reports what actually landed. 5. **Status.** `adsops_status` is the live read: status, end date, daily budget, spend today and lifetime, negative count, whether caps come from the policy file, and the guard heartbeat's age. Reads are unlimited and always safe. `adsops_set` is the one-call shortcut through steps 1–4: it checks the proposal against policy caps and live state, writes the state file and commits it to git with the stated `reason`; `dry_run=true` returns a `plan_id` instead of writing. --- ## The policy file One policy file per campaign, referenced by `policy_file` in the config. AdRails only ever reads it: no tool writes caps, and `adsops_set` refuses `guards` and `allow_enable` outright. If the state file also carries caps they are ignored — the policy file always wins, and `adsops_status` says so (`caps_from: "policy"`, plus a warning when the two disagree). ```json { "customer_id": "000-000-0000", "campaign_id": 1000000000, "guards": { "max_daily_budget_usd": 40, "min_daily_budget_usd": 5, "max_end_date": "2026-12-31", "lifetime_spend_pause_usd": 900, "daily_spend_pause_usd": 60, "max_ops_per_run": 12 }, "allow_enable": false, "max_guard_age_min": 30, "require_plan_id": true, "plan_ttl_s": 900 } ``` | Field | Meaning | | --- | --- | | `customer_id` | Account the campaign lives in; compared with the state file after stripping dashes. A mismatch is refused. | | `campaign_id` | The one campaign this policy governs; must match the state file exactly. | | `guards.max_daily_budget_usd` | Required. Upper bound of the daily budget, and the budget ceiling checked at plan time. | | `guards.min_daily_budget_usd` | Optional. Lower bound of the daily budget; must not exceed the maximum. | | `guards.max_end_date` | Required, `YYYY-MM-DD`. Latest end date any plan may set. | | `guards.lifetime_spend_pause_usd` | Required. Lifetime spend at which the guard issues a pause. | | `guards.daily_spend_pause_usd` | Required. Today's spend at which the guard issues a pause. | | `guards.max_ops_per_run` | Required integer. Most operations one run may emit; more is refused. | | `allow_enable` | Default `false`. Whether a plan may ever set the campaign to `ENABLED`. | | `max_guard_age_min` | Default `null` (check off). When set, any change that adds spend exposure is refused unless the guard heartbeat is younger than this. | | `require_plan_id` | Default `false`. When `true`, apply/commit and `adsops_set` with `apply=true` must quote a `plan_id`. | | `plan_ttl_s` | Default `900`, range 60–86400. How long an issued `plan_id` stays usable; each one is consumed once. | A missing or malformed field is an error that names the field, not a silent default. ## Desired-state file One JSON file per campaign, referenced by `state_file`. It says what the campaign should look like; the server computes the diff. ```json { "customer_id": "000-000-0000", "campaign_id": 1000000000, "campaign_name": "Search - Core", "status": "PAUSED", "end_date": "2026-11-30", "daily_budget_usd": 20, "negatives_add": [ {"text": "free", "match_type": "BROAD"}, {"text": "cheap", "match_type": "PHRASE"} ], "guards": { "max_end_date": "2026-12-31", "min_daily_budget_usd": 5, "max_daily_budget_usd": 40, "lifetime_spend_pause_usd": 900, "daily_spend_pause_usd": 60, "max_ops_per_run": 12 } } ``` | Field | Meaning | | --- | --- | | `customer_id` | Account the campaign lives in; dashes are stripped before use. | | `campaign_id` / `campaign_name` | The one campaign this file owns. Both must match live state or the run aborts. | | `status` | Desired status, `ENABLED` or `PAUSED`. `ENABLED` additionally requires `allow_enable` in the policy. | | `end_date` | Desired end date, `YYYY-MM-DD`, bounded by `guards.max_end_date` and not in the past. | | `daily_budget_usd` | Desired daily budget, bounded by the min/max guard band. | | `negatives_add` | Append-only list of `{text, match_type}`; match type is `BROAD`, `PHRASE` or `EXACT`. Nothing here removes a keyword. | | `guards` | Read only when the campaign has no `policy_file` (legacy mode). With a policy file configured, these are ignored. | Keep these files in git. That is what makes "reviewed desired state" real: `git diff` is the change review, and `require_clean_git` makes the server refuse to act on uncommitted edits. --- ## The rails The rails are enforced by the executor in code, not by prompts. Each is covered by the test suite. 1. Caps in a policy file that no AdRails tool can write: max daily budget, end date, daily and lifetime pause thresholds, ops per run. 2. Single-use plan IDs: a commit must quote the exact plan it was shown; changed, reused or expired plans are refused. 3. Fail closed when nobody's watching: budget raises, re-enables and end-date extensions are blocked while the guard heartbeat is stale. 4. Pause-only spend guard: the scheduled guard pauses on threshold breach, and can never enable or raise anything. 5. Validate before every commit: Google checks the identical payload in validate-only mode first; nothing lands if that fails. 6. Reconcile, never blind-retry: if Google times out mid-commit, AdRails re-reads the account and reports what actually landed. 7. Every change has a reason: agent changes are committed to git with their stated reason, and every call is metered and audited. 8. Protected terms: listed keywords that pay the bills cannot be blocked by a negative keyword. Around a commit, the executor also takes an account lock (lease files in `lock_dir`, so two writers cannot collide), writes an audit line per call (`via == "adsops"`, tenant-tagged, credentials scrubbed — `Bearer [REDACTED]`), and meters every call into the log behind `adsops_usage`. --- ## The guard `adsops_guard` (and the scheduled `guard-all` command) reads spend and pauses the campaign if the daily or lifetime threshold is crossed. Pause only: it never enables, raises a budget or edits keywords. Validate-only unless `commit=true`; when it commits, it emits validate-then-commit against the `campaigns` service and nothing else. - Daily threshold: `guards.daily_spend_pause_usd` — today's spend at which a pause is issued. - Lifetime threshold: `guards.lifetime_spend_pause_usd` — lifetime spend at which a pause is issued. - Heartbeat: every guard run writes `guard_heartbeat` (an ISO-8601 UTC timestamp). Its age is surfaced by `adsops_status` and gates any exposure-increasing change when `max_guard_age_min` is set. --- ## Limits, and what AdRails does NOT do AdRails reduces risk. It does not remove it. You run it at your own risk; you own your account and your spend. Read this before pointing it at a live account. - **It is a threshold with automatic pause attempts, not a hard spend ceiling.** The guard compares spend with numbers you wrote and issues pause operations. That is containment, not billing control. - **Google can deliver up to 2x the average daily budget on a given day.** A daily budget is an average, not a maximum, and a pause is an API call that can be rejected or land late. - **Reporting is delayed.** The spend figures AdRails reads are minutes to hours behind delivery. - **The guard only acts when it is scheduled and the machine is awake.** While the machine sleeps no checks run, spend keeps moving, and the heartbeat goes stale. A chat-session MCP process is not a monitor. - **An agent with shell access as the same user can bypass any local tool.** AdRails constrains the tool calls it serves, not the machine underneath: such an agent can edit files or call Google directly. Sandbox the agent or run the executor as its own user if that matters to you. - **Wrong negative keywords can suppress good traffic.** Negatives are append-only here, so a bad one is also awkward to withdraw. - **You are responsible for your account and your spend.** Keep platform-side billing limits as the real ceiling. ### What AdRails does NOT do - It does not host anything and does not proxy Google calls: the only endpoint the package talks to is the Google Ads API, with your credentials, from your machine. - It does not let any tool write the caps. No tool writes the policy file; `adsops_set` refuses `guards` and `allow_enable` outright. - It does not enable campaigns, raise budgets or edit keywords from the guard. The guard is pause-only. - It does not remove negative keywords. Negatives are append-only. - It does not let a negative keyword hit a protected term: listed keywords that pay the bills cannot be blocked by a negative keyword. - It does not blind-retry a timed-out commit. It reconciles: re-reads the account and reports what actually landed. - It does not monitor spend from a chat session. Only a scheduled guard on an awake machine checks spend; while the machine sleeps, nothing does. - It does not protect against an agent that bypasses local tools with shell access as the same OS user. --- ## Works with Google's official read-only `google-ads-mcp` Run both servers side by side in the same MCP client. Google's server answers ad-hoc GAQL exploration and never writes; AdRails (adrails.dev) is the write path. The agent reads with the official server, proposes a change to a desired-state or policy file, and applies it through `adsops_plan` / `adsops_apply` — with caps, a validate pass, an account lock, and an audit line in between. Both speak JSON-RPC over stdio, so one `.mcp.json` entry each is the whole setup. --- ## Pricing - The executor is free forever, Apache-2.0. No subscriptions, no seats, no account with AdRails. - Optional services are paid per call in sats over Lightning (L402 first), coming soon: an independent AI second opinion on a plan before it commits, pause alerts to Telegram/Slack/SMS, and audit-log anchoring to Bitcoin. - Optional services never touch your Google credentials and never proxy Google calls. ## Links - [Home](https://adrails.dev/): overview, hero, quickstart entry point - [How it works](https://adrails.dev/#how): plan, prove, commit - [The rails](https://adrails.dev/#rails): all eight rails - [Limits](https://adrails.dev/#honest): where the edges are - [Pricing](https://adrails.dev/#pricing): free core, sats per call - [Quickstart](https://adrails.dev/#start): config, policy file, MCP registration - [Short agent entry point](https://adrails.dev/llms.txt) - [MCP server card](https://adrails.dev/.well-known/mcp.json) ## Source - GitHub: https://github.com/antonioac1/adrails (Apache-2.0) - MCP Registry name: `dev.adrails/adrails` ## License Apache-2.0.