Install AdRails with Claude CodeFirst validated plan in about 5 minutes · adrails.dev
AdRails is the open-source MCP server for Google Ads that Claude Code can read freely and write to only through your policy. This page installs it, registers it, schedules the guard and gets a first plan that changes nothing.
Direct answer. AdRails (adrails.dev) is a local, open-source MCP server that lets Claude Code read your Google Ads account freely and write to it only through a policy file no AdRails tool can write. You install the adsops package, point it at your own Google Cloud credentials, register it over stdio, and ask Claude for a plan. The first plan comes back as a diff with a single-use plan ID and commits nothing.
Your own Google Ads API access
AdRails has no hosted proxy and no token custody. It runs against your own Google Cloud project, which also follows Google's policy that each business uses its own Cloud project and access.
- A Google Cloud project with the Google Ads API enabled, and a developer token for it.
- A service account added to your Google Ads account with the access you want the agent to have, and its JSON key file on disk.
- The customer ID of the account and the campaign ID of the one campaign you are starting with.
- Python 3 on the machine, plus
pipxoruvx, and Claude Code installed and working. - A git repository for the desired-state and policy files.
git diffis the change review, andrequire_clean_gitis on by default.
Six steps to a first validated plan
- Install the package
The Python package and module are named
adsopsand the console script isadsops-mcp. There are no other dependencies: service-account tokens are signed with your systemopensslwhengoogle-authisn't installed.shell · from GitHubPyPI release coming soon# 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
Check it offline first. Everything is mocked and no network call is made.
shell · offline checkpython3 -m adsops --selftest # or: deploy/smoke_docker.sh - Write a config
Every account, credential source and path lives here. Paths are
~-expanded at load time. Save it somewhere absolute, for example~/.adsops/config.json.config.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_policydefaults torequire_clean_git: trueandallow_commit: false, so commits andadsops_setstay off until you turn them on. The example above shows them enabled for a first run on a sandbox campaign; leave them off on a live account until you have read a few plans. - Write the policy file
One policy file per campaign, referenced by
policy_filein the config. AdRails only ever reads it: no tool writes caps, andadsops_setrefusesguardsandallow_enableoutright. If the state file also carries caps they are ignored, andadsops_statussays so.search_core.policy.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 }Commit both the config-referenced files and the policy file to git so
git diffis the change review. A missing or malformed field is an error that names the field, not a silent default. - Register the MCP server in Claude Code
Diagnostics go to stderr; the server speaks JSON-RPC 2.0 over stdio, one object per line. Use an absolute path for the config, and add the entry to the
.mcp.jsonyour Claude Code session reads..mcp.json{ "mcpServers": { "adrails": { "command": "python3", "args": ["-m", "adsops", "--config", "/absolute/path/config.json"] } } }Google's official
google-ads-mcpserver is read-only and can run beside AdRails in the same client: the agent explores ad-hoc GAQL with Google's server and writes only through AdRails. - 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 readdeploy/GUARD.mdbefore installing. The command all three units run isPYTHONPATH=$PKG $PY -m adsops --config $CFG guard-all.shell · macOS launchdPY=$(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.plistWith
max_guard_age_minset, any change that adds spend exposure is refused while the guard heartbeat is stale, so this step is what makes budget raises possible at all. The guard is pause-only: it never enables, raises a budget or edits keywords. - Ask Claude for the first validated plan
In Claude Code, ask it to list what it can operate, read live state, then plan. Three tool calls, no writes:
Call What comes back adsops_campaignsCampaign keys, accounts, state files and the active commit policy for this deployment. adsops_statusLive 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_planA pure diff against live state: the operations, the notes, ops_count, the exposure increases it found, and a single-useplan_idwith its expiry.A good opening prompt: “Call adsops_campaigns, then adsops_status for search_core, then adsops_plan. Don't commit anything, just show me the diff and the plan ID.” You now have a validated plan: the diff is against the live account, checked against your caps, and nothing has been submitted.
shell · what the plan is, and is not▸ adsops_plan campaign=search_core ✓ planned diff vs live account · caps checked · exposure: none plan_id … (single use · 900 s) nothing written, nothing submitted
Committing later is a separate call.
adsops_applyvalidate-only unlesscommit=true, and a commit needscommit_policy.allow_commit, a clean git-tracked state file, a fresh guard heartbeat when the change adds exposure, and theplan_idwhen the policy requires it. Google validates the identical payload in validate-only mode first; ambiguous failures are reconciled by re-reading the account, never blind-retried.Last tested with AdRails 0.2.2. Commands on this page are copied from the repository README; if a flag has moved, the README wins.
When the first run doesn't take
The MCP server doesn't appear in Claude Code
Check the config path is absolute and that python3 -m adsops --config … runs from the same shell Claude Code launches. Diagnostics go to stderr, not to the JSON-RPC stream; a server that prints startup errors to stdout is the usual cause of a client that hangs. python3 -m adsops --selftest isolates package problems from config problems.
Authentication keeps failing
AdRails reads credentials from the sources you configure: a service-account JSON file, a static token, or a secret-manager lookup. Confirm the JSON key file exists at the configured path, that the service account is added to the Google Ads account, and that the Cloud project has the Google Ads API enabled with a valid developer token. The login_customer_id field matters when you operate a manager account.
A commit or adsops_set is refused
Expected until you opt in. commit_policy.allow_commit defaults to false and allow_set stays off; and require_clean_git: true refuses to act on uncommitted edits to the state file. Commit the file, then try again.
“Guard heartbeat is stale”
The guard has not run inside max_guard_age_min. Check the scheduler unit is loaded and that the machine was awake during the last 15-minute window. Anything that adds spend exposure stays refused until a fresh heartbeat lands — that is the rail doing its job, not a bug.
The plan ID was rejected
A plan_id is single-use and expires after plan_ttl_s (default 900, range 60–86400). A changed, reused or expired plan is refused, so re-run adsops_plan and quote the new ID.
The campaign won't go to ENABLED
Setting a campaign to ENABLED needs allow_enable: true in the policy file. Re-enables are also treated as spend exposure and need a fresh heartbeat. Caps and allow_enable are the two things adsops_set refuses outright.
A customer or campaign ID mismatch
The policy file's customer_id is compared with the state file after stripping dashes, and campaign_id must match exactly. A mismatch is refused before anything is planned.
What this install does not protect you from
AdRails reduces risk; it does not remove it. Read these before pointing it at a live account.
- A threshold, not a spend ceiling. Caps bound what AdRails submits. A daily budget is an average, not a maximum, and a pause is an API call that can be rejected or land late. Keep platform-side billing limits as the real ceiling.
- Google can deliver up to 2× the average daily budget on a given day. Reporting is minutes to hours behind delivery, so the spend figures AdRails reads can understate what is already gone.
- The guard only acts when it is scheduled and the machine is awake. A chat-session MCP process is not a monitor: while the machine sleeps no checks run, spend keeps moving, and the heartbeat goes stale.
- 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. Run the executor as its own user and sandbox the agent if that matters to you.
- Wrong negative keywords can suppress good traffic. Negatives are append-only, so a bad one is also awkward to withdraw. List your protected terms.
- You are responsible for your account and your spend. AdRails is Apache-2.0 and runs on your machine with your keys; there is no hosted partner holding the token for you.