Skip to content

Releases: kLOsk/adloop

v0.9.0 — new tagline, manual OAuth default, estimate_budget zero-value fix

15 May 06:54

Choose a tag to compare

What's New

This is a positioning + UX release driven by Google's OAuth verification status, plus the second half of the zero-value bug class fixed in 0.8.1.

New tagline

The AI command center for Google Ads, GA4, and tracking code.

Replaces the older "stop switching between dashboards" framing. It says concretely what AdLoop runs across — Ads, GA4, and tracking code — instead of describing a problem. Updated in the PyPI description and the README header.

Setup wizard now defaults to "bring your own Google Cloud project"

AdLoop's built-in OAuth client has reached Google's 100-user cap on unverified apps. New users who picked the bundled credentials in the wizard were getting stranded with a "This app is blocked" error at the Google consent screen — there's no way through that page without an approved app.

  • adloop init now defaults the credentials prompt to No (bring your own GCP) instead of Yes (use bundled).
  • An up-front warning explains the cap and links to Discussion #13 for verification status.
  • Bundled credentials stay available as an opt-in for existing users whose tokens predate the cap — their tokens keep working; this only affects fresh sign-ins.
  • README "Setup" section restructured to put the custom-GCP path front and center while verification is pending. The collapsible "Advanced Setup" is gone — those steps are now the primary path, renamed to "Custom Google Cloud Project Setup".

When Google verification completes we'll flip the default back to bundled in a follow-up release. Status updates live on Discussion #13.

Bug fix — estimate_budget zero values silently became None

Same class of bug as 0.8.1's discover_keywords fix (Bug 1). The forecast service can legitimately return zero clicks, zero cost, or zero CTR — that's the diagnostic signal for insights like "impressions but zero clicks" or "budget covers 100% of estimated cost". The old code used falsy guards (round(cost / 1_000_000, 2) if cost else None, round(clicks / days, 1) if clicks else None) which mapped those legitimate zeros onto None, hiding the signal.

  • Now uses _micros_to_currency (shared with discover_keywords) for the cost/CPC conversions.
  • Daily averages and CTR rounding use explicit is not None guards.
  • New TestEstimateBudgetZeroPreservation suite pins the behavior end-to-end across four cases: zero clicks, zero cost, all-None metrics, and the realistic happy path.

Tests: 262 pass (was 258 before the new suite).


Upgrade path

pip install --upgrade adloop

If you're an existing user: nothing to do. Your current OAuth token keeps working until it expires.

If you're hitting "This app is blocked" on a fresh install: that's the 100-user cap. Re-run adloop init and take the default — the wizard guides you through setting up your own Google Cloud project (~5 minutes, no cap).

Full changelog

v0.8.1...v0.9.0

v0.8.1

15 May 06:43

Choose a tag to compare

v0.8.1 — Preserve zero values in discover_keywords output

A small, targeted patch fixing one data-fidelity bug introduced in v0.8.0's gRPC → REST migration of discover_keywords.

The bug

discover_keywords used falsy checks (int(v) if v else None) to normalise REST int64 fields. The pattern silently mapped a legitimate 0 to None and lost real data — most visibly on the bid micros bounds:

low_bid_int = int(low_bid_micros) if low_bid_micros else None
...
"low_top_of_page_bid": (
    round(low_bid_int / 1_000_000, 2) if low_bid_int else None
),

REST sends int64 fields as JSON strings per the proto3 JSON spec, so the string "0" is truthy and survives the first conversion. But once parsed to int 0, it's falsy and the second step swaps it for None. The result: a keyword with lowTopOfPageBidMicros = "0" (Google's representation when no bid data is available, or when the low bound of a competitive range is genuinely 0) came out the other end as low_top_of_page_bid = None — indistinguishable from "field absent". Users couldn't tell missing data apart from "Google has data and the value is 0".

The same defect was present on the high bid bound, and the same fragile first-step pattern lived on avgMonthlySearches and competitionIndex — those happened to round-trip correctly today because their intermediate _int locals are stored directly into the output dict without a second falsy gate, but any future code that re-used them would inherit the bug.

The fix

Two small helpers replace the inline conversions, both using explicit is not None rather than falsy checks:

  • _maybe_int(value) — proto3-JSON int64 parser. Treats only None and empty string as "missing"; preserves 0 exactly; falls back to None on malformed input rather than crashing the whole response.
  • _micros_to_currency(micros) — micros → 2-dp float. Preserves 0 (becomes 0.0, not None) and None.

The output dict now reads:

ideas.append({
    "keyword": idea.get("text", ""),
    "avg_monthly_searches": _maybe_int(metrics.get("avgMonthlySearches")),
    "competition": competition,
    "competition_index": _maybe_int(metrics.get("competitionIndex")),
    "low_top_of_page_bid": _micros_to_currency(
        _maybe_int(metrics.get("lowTopOfPageBidMicros"))
    ),
    "high_top_of_page_bid": _micros_to_currency(
        _maybe_int(metrics.get("highTopOfPageBidMicros"))
    ),
})

This pattern can't regress to the original bug by accident — the helpers are the only path that constructs the output, and both are is not None.

Action for maintainers

estimate_budget (same module, gRPC path) has the same falsy-check pattern on clicks / impressions / cost_micros / avg_cpc_micros / total_cost. A legitimate clicks=0 or cost_micros=0 would collapse to None there for the same reason. This is pre-existing behaviour, not regressed by v0.8.0, so this patch is intentionally scoped to discover_keywords (the reported bug). File a separate issue if you want the same defensive treatment for estimate_budget.

Verification

  • 258 tests pass (243 v0.8.0 baseline + 15 new in this release)
  • New coverage:
    • TestZeroValuePreservation — end-to-end checks that low_bid=0, high_bid=0, competition_index=0, and avg_monthly=0 all survive the round-trip
    • TestZeroValuePreservation::test_missing_fields_still_return_none — guard against over-correction
    • TestMaybeIntHelper — unit coverage including the specific wire format "0" regression
    • TestMicrosToCurrency — unit coverage including _micros_to_currency(0) == 0.0

Install / upgrade

pipx upgrade adloop
# or
pip install --upgrade adloop
# or
uvx adloop@0.8.1

Restart your MCP host after upgrading.

Full Changelog: v0.8.0...v0.8.1

v0.8.0

15 May 06:35

Choose a tag to compare

v0.8.0 — Shared-set linkage + four bug fixes

A focused bug-fix-plus release cleaning up four real-world issues that landed in the tracker over the past few weeks, plus the missing CampaignSharedSet write surface. No new OAuth scopes — everything in this release operates inside the existing adwords + analytics.readonly permission set, important while the app's Google verification is in flight.

New tools

  • attach_shared_set_to_campaigns(shared_set_id, campaign_ids) — creates CampaignSharedSet linkages so the campaigns inherit the shared set's criteria (most commonly: shared negative keyword lists). Closes the gap between "create a shared negative list" and "have it actually apply to a campaign". Newly-built campaigns don't auto-inherit shared lists, so this is the easy-to-forget step right after draft_campaign.
  • detach_shared_set_from_campaigns(shared_set_id, campaign_ids) — removes the linkages. The shared set and its keywords stay intact; only the per-campaign attachment record goes. Uses the composite {campaign_id}~{shared_set_id} resource name so no GAQL lookup is needed.

Both tools follow the existing draft → preview → confirm_and_apply flow, validate shared_set_id + campaign_ids are numeric and non-empty, dedupe input lists, and use partial_failure=True so per-operation errors (e.g. attaching a set to a campaign that already has it) don't fail the whole batch — succeeded vs failed linkages are surfaced separately in the apply response. Credit to @SullyGitHub (PR #22) for the full implementation.

Bug fixes

update_campaign silently dropped negative geo exclusions (#32)

This was a real data-safety bug. When you changed geo_target_ids on a campaign, the existing-criteria removal query filtered on campaign_criterion.type = 'LOCATION' alone — which swept up criteria with negative=TRUE and silently deleted them along with the positives. Users only noticed when traffic from an excluded region started showing up in reports.

Fix scope:

  • Restrict the removal GAQL to campaign_criterion.negative = FALSE so the positive-geo replacement only sweeps positives.
  • The preview now surfaces preserved negative geo exclusions via a new preserved_negative_geo_target_ids field plus a warning entry, so the change is explicit and auditable rather than silently happening behind the scenes.
  • The orchestration rules now call out the new behaviour so AI clients don't try to "fix" the preservation by re-adding the negatives.
  • To remove a negative geo exclusion explicitly, use remove_entity with entity_type="campaign_criterion".

Reported by @PeterrrPiperrr.

discover_keywords hit RESOURCE_EXHAUSTED after a handful of sequential calls (#37)

KeywordPlanIdeaService.GenerateKeywordIdeas sits in a notoriously tight gRPC quota bucket. 5-10 sequential calls with different geo targets was enough to trigger RESOURCE_EXHAUSTED for the rest of the session, regardless of QPS. The v23 REST endpoint for the same method lives in a separate, much larger quota bucket.

discover_keywords now calls the REST endpoint directly via AuthorizedSession:

POST https://googleads.googleapis.com/v23/customers/{cid}:generateKeywordIdeas
  • Same OAuth token (no new scopes — uses the existing adwords scope)
  • Same developer token + login-customer-id MCC header semantics
  • Pagination via nextPageToken is followed transparently so response semantics ("all matching ideas") match the previous gRPC iterator
  • HTTP 429 from REST is surfaced as a RESOURCE_EXHAUSTED string so the existing call_with_retry exponential-backoff helper still kicks in
  • All other Ads tools (run_gaql, get_campaign_performance, every draft_* write tool) keep using the gRPC client — the swap is scoped strictly to the one method that hit the quota wall.

Reported by @PeterrrPiperrr.

update_ad_group(max_cpc=...) error message was misleading for automated bidding (#31)

The old refusal — "max_cpc requires an ad group in a MANUAL_CPC campaign" — implied a technical limitation. The reality is that ad-group CPC bids are simply ignored under every automated bidding strategy (effective_cpc_bid_micros = 0 per Google's docs), so the bid would be a no-op anyway.

The error message now names the actual strategy and points at the right next step:

  • For TARGET_SPEND (Maximize Clicks): "Maximize Clicks (TARGET_SPEND) ignores ad-group CPC bids. The campaign cpc_bid_ceiling is the active constraint — set it via update_campaign(max_cpc=...) instead. No change made."
  • For any other automated strategy: "{strategy} ignores ad-group CPC bids (effective_cpc_bid_micros = 0). The campaign-level target governs spend under automated bidding. No change made."

The MANUAL_CPC happy path is unchanged. The internal helper _ad_group_uses_manual_cpc was renamed to _ad_group_campaign_bidding_strategy and now returns the actual strategy name so the error layer can be specific.

Reported by @PeterrrPiperrr.

run_ga4_report (and every other list-taking tool) rejected JSON-string list args (#28)

Some MCP clients (Cowork at the time of writing) serialize list-typed tool arguments as JSON-encoded strings rather than as native JSON arrays. So a call that should look like {"dimensions": ["pagePath"]} on the wire arrived as {"dimensions": "[\"pagePath\"]"} — Pydantic v2's strict list validator saw a string, refused to coerce, and raised Input should be a valid list. Claude Code, Cursor, and other clients send native arrays so they were unaffected; this was a serialization mismatch on the client side, locking Cowork users out of every list-taking tool.

Fix: a targeted Pydantic BeforeValidator (_coerce_json_string_to_list) now runs on every list-typed tool parameter — 25 callsites across GA4, Ads read/write, GTM, planning, tracking, and cross-reference tools. The validator detects the JSON-array shape ("[...]"), decodes it, and hands the resulting list to the standard list validator. The fix is invisible to schema generation, so well-behaved clients keep seeing the same {"type": "array", "items": {...}} schema and keep sending native arrays.

Four edge cases handled explicitly:

  • Native lists pass through untouched.
  • None passes through (optionals still accept null).
  • Bare strings that aren't valid JSON pass through so Pydantic emits the standard "valid list" error — those genuinely aren't lists.
  • Non-list JSON values (numbers, objects, quoted strings) pass through too, so they fail loudly rather than being silently wrapped.

Reported by @sg-modlab.

Also in this release

  • adloop init no longer crashes on Windows paths (#30, credit @JesseLeeStringer). The wizard previously wrote credentials_path: "c:\Users\..." inside double-quoted YAML, which the parser treated as a \U Unicode escape and rejected with ScannerError. Single-quoted YAML is now used for credentials_path — backslashes are literal in single-quoted strings, and embedded apostrophes are escaped per the YAML spec.
  • Dynamic Google Ads enum introspection helper (#33, credit @illia-sapryga). New adloop.ads.enums.enum_names() pulls valid enum member names straight from the google-ads SDK at the API version we're pinned to. Avoids the drift between hand-maintained validator sets and the SDK — during development this revealed our hardcoded ConversionActionTypeEnum set was missing 29 of 40 valid types.

Closed issues

  • #28run_ga4_report Pydantic list validation
  • #31update_ad_group max_cpc error wording
  • #32update_campaign negative-geo silent removal
  • #36 — RSA pinning support (already shipped in v0.6.0; closed as "already implemented")
  • #37discover_keywords gRPC RESOURCE_EXHAUSTED

Verification

  • 243 tests pass (184 v0.7.0 baseline + 59 new across the changes)
  • No new OAuth scopes — Google verification scope unchanged
  • All write tools continue to follow the draft → preview → confirm_and_apply flow with dry_run=true default

Install / upgrade

pipx upgrade adloop
# or
pip install --upgrade adloop
# or
uvx adloop@0.8.0

Restart your MCP host (Cursor / Claude Code / etc.) after upgrading so the new tool signatures register.

Credits

Full Changelog: v0.7.0...v0.8.0

v0.7.0

28 Apr 13:36

Choose a tag to compare

v0.7.0 — RSA pinning + globally installable Claude rules

Two features land together. The first closes a real brand-safety gap in draft_responsive_search_ad. The second makes AdLoop's orchestration intelligence available in Claude Code sessions outside this repo, so Claude users get the same safety patterns and tool guidance that Cursor users have always had via workspace rules.

New: RSA headline & description pinning

draft_responsive_search_ad now accepts pinned fields per asset. Headlines and descriptions can be either plain strings (unpinned) or dicts of the form {"text": "...", "pinned_field": "HEADLINE_1"}. Mixed shapes are allowed in a single call — pin the brand to HEADLINE_1, leave the rest unpinned.

draft_responsive_search_ad(
    headlines=[
        {"text": "Your Brand", "pinned_field": "HEADLINE_1"},
        "Free trial today",
        "Save 30% off",
        "Quick & easy setup",
    ],
    descriptions=[
        {"text": "Trusted by 1000+ teams", "pinned_field": "DESCRIPTION_1"},
        "Get started in minutes.",
    ],
    final_url="https://example.com/",
)
  • Valid pin slots: HEADLINE_1, HEADLINE_2, HEADLINE_3, DESCRIPTION_1, DESCRIPTION_2.
  • Google permits at most 2 headlines per slot, 1 description per slot — _validate_rsa enforces both caps at draft time.
  • Backward compatible: headlines: list[str] continues to work for every existing caller.

Before this release, the only options were (a) accept Google's auto-rotation across all assets, or (b) bypass AdLoop entirely and drop into google-ads-python for any campaign that needed brand or compliance pins. Option (a) was a real brand-control loss; option (b) defeated the purpose of the safety layer.

New: adloop install-rules (and update-rules / uninstall-rules)

A new family of CLI subcommands installs AdLoop's orchestration rules + slash commands at the user level (~/.claude/), so every Claude Code session inherits them — not just sessions launched inside this repo.

adloop install-rules            # default: inline mode
adloop install-rules --lazy     # cheaper baseline cost
adloop install-rules --no-commands

adloop update-rules             # refresh after upgrading
adloop uninstall-rules          # surgical removal

The adloop init wizard also detects Claude installations now and offers to install at the end of setup.

Two install modes, with an explicit context-cost trade-off:

  • inline (default) — full rules embedded in ~/.claude/CLAUDE.md between sentinel comments. Reliable; the LLM has the rules in-context every time. Adds ~10K tokens to every Claude Code session.
  • lazy (--lazy) — short directive in CLAUDE.md pointing at ~/.claude/rules/adloop.md. The LLM reads the rules file only when AdLoop tools are in scope. Cheaper baseline cost; depends on the LLM following the directive.

Idempotency is handled by versioned sentinel comments:

<!-- adloop:rules:start v0.7.0 -->
... managed content ...
<!-- adloop:rules:end -->

update-rules detects drift across versions and replaces stale blocks cleanly. uninstall-rules only touches the managed block and adloop-* prefixed slash commands — anything you authored yourself in CLAUDE.md is preserved verbatim. Run install-rules ten times in a row, you still get exactly one block.

Claude Desktop / claude.ai has no programmatic rules location, so the installer detects it and prints copy-paste instructions for Project settings → Custom instructions on claude.ai.

Updated orchestration rules

.cursor/rules/adloop.mdc (and the synced Claude / package copies) now document pinning:

  • The draft_responsive_search_ad row in the tools table describes the str | dict entry shape and pin-slot caps.
  • The "When user wants to create an ad" pattern gets a dedicated step on when to pin, the ad-strength trade-off, and — for phone numbers specifically — the recommendation to use a campaign-level call asset rather than a pinned headline.
  • The RSA best-practices entry was strengthened: "pin only when necessary" now spells out why (reduces Google's rotation, lowers ad strength) and points at the dict shape.

Architecture

  • New src/adloop/rules/ package directory ships the rules + slash commands inside the wheel via uv_build's default file inclusion. Verified by building locally and inspecting the wheel.
  • scripts/sync-rules.py now writes three targets from the canonical .cursor/rules/adloop.mdc source: .claude/rules/adloop.md (in-repo Claude Code), src/adloop/rules/adloop.md (bundled), and src/adloop/rules/commands/*.md (mirrored from .claude/commands/). Single source of truth maintained.
  • New src/adloop/rules_install.py module exposes detect_clients, install_rules, update_rules, uninstall_rules. Frontmatter is stripped for inline mode (CLAUDE.md is itself a rules file and shouldn't contain nested frontmatter) but preserved for the lazy-mode sibling file.

Safety

  • RSA pinning validation runs at draft time, before any preview is returned. Invalid pin slot values, slot-cap violations, and length overruns on dict entries are all caught and surfaced in validation failed → details[] rather than letting them reach the Google Ads API.
  • The pinning apply path uses client.enums.ServedAssetFieldTypeEnum[...] lookup — no hand-built enum integers.
  • install-rules writes are idempotent and version-tagged. Sentinel comments are HTML-comment-style so they're invisible in rendered Markdown but trivially regex-matchable in source. The block uses a single regex match on uninstall — no false positives in user content possible unless they also authored exactly the AdLoop sentinel comment, which is namespaced as adloop:rules:*.

Verification

  • 184 tests passing (158 baseline + 13 new for RSA pinning + 26 new for install-rules + 12 net updates from sync). Full suite passes on Python 3.11–3.13.
  • RSA pinning was live-smoke-tested through Cursor — preview correctly rendered the pin column, validation caught invalid pin names, mixed-shape calls worked end-to-end. The existing string-only callers continue to work (covered by unchanged tests).
  • install-rules was end-to-end verified locally against a temp $HOME: inline install preserves user content above the block, second install reports updated (not duplicated), --lazy writes the small directive + 580-line rules file, update-rules without flags preserves the existing mode, update-rules --inline switches modes correctly, uninstall-rules cleans up the block + lazy file + adloop-* commands while leaving everything else intact.

Also in this release

  • The init wizard's MCP-config snippet section now points users at adloop install-rules rather than instructing them to manually copy .claude/rules/ and .claude/commands/ into their project. Manual copy remains documented for users who prefer it.

Install / upgrade

pipx upgrade adloop                  # if installed via pipx
pip install --upgrade adloop         # if installed via pip
uvx adloop@0.7.0                     # one-off run via uvx

After upgrading, run adloop update-rules to refresh the global rules block to the v0.7.0 sentinel. Restart your MCP host (Cursor reload, Claude Code restart) to pick up the new tool signature for draft_responsive_search_ad.

Credits

  • @SullyGitHub — RSA pinning (#21), with 13 tests covering canonical pin patterns, slot caps, and validation edge cases.
  • @sg-modlab — surfaced the global-rules gap that motivated the install-rules feature (#23).
  • @luison — flagged the context-bloat concern that shaped the inline-vs-lazy mode trade-off (#18).

Full Changelog: v0.6.5...v0.7.0

v0.6.5

28 Apr 09:38

Choose a tag to compare

v0.6.5 — Raise list_accounts default cap from 50 to 200

If you've been running AdLoop on an agency MCC and your assistant kept saying "I can only see 50 of your accounts" — this is for you. The 50-account default cap added in v0.6.3 was a defensive measure tied to a separate bug; that bug is now fully fixed at the protocol layer, and 50 was too low for normal-sized agencies. The default is now 200.

Why 50 existed

v0.6.3 introduced _mcp_patches.py to fix python-sdk#2416, the cancellation race that crashed the entire MCP server when an MCP host sent notifications/cancelled while a long-running Google Ads call was still in-flight. Large customer_client enumerations on 100+ account MCCs were one of the most common triggers. Alongside the protocol fix, list_accounts got a conservative limit=50 cap as belt-and-braces.

Why 50 had to go

The cancellation crash and the per-response payload size on the host side are two different problems. v0.6.3's _mcp_patches.py fixed the crash. The cap was meant to also keep responses under per-tool size/timeout limits on some MCP hosts — that concern is real, but only for very large MCCs. For typical agencies running 50-150 client accounts, the cap was just hiding accounts and forcing users to remember a magic incantation.

What changed

  • Default raised to 200 in list_accounts (both the implementation in src/adloop/ads/read.py and the MCP tool wrapper in src/adloop/server.py).
  • Truncation note is actionable, not vague. Old: "Call list_accounts with a higher limit." New: "If the user asked to see all of their accounts, call this tool again with a much higher limit (e.g. list_accounts(limit=1000)). If you only need one specific account, skip listing entirely and pass customer_id directly to get_campaign_performance, run_gaql, or whichever tool you actually need." LLMs follow concrete instructions; they ignore vague ones.
  • Tool docstring updated with the same explicit fallback so agents see the override path in the tool schema, not just post-hoc.
  • The cap itself stays. Very large MCCs (300+ accounts) can still hit host-side response-size or per-tool-call timeouts — independent of the SDK race — and the right move there is for the caller to ask for a bigger page on purpose.

Verification

145 unit tests pass (uv run pytest), including three new ones covering the new default, the new truncation note shape, and the explicit-limit-overrides-default path (verifying that the underlying GAQL probe correctly requests LIMIT {limit + 1}).

Install / upgrade

pipx upgrade adloop        # or
pip install --upgrade adloop
# or on-demand:
uvx adloop@0.6.5

Then restart your MCP host (Claude Desktop, Cursor, Claude Code, Cowork, etc.).

If you have a 200+ account MCC and want every account in a single response, just ask your agent for "all of my accounts" — it'll see the new truncation note and call list_accounts(limit=1000) (or similar) on its own.

Credits

Thanks to a Cowork agency user who flagged that 50 was hiding most of their book — exactly the regression we needed to know about. The original 166-account MCC report that motivated v0.6.3 still applies: the cap protects them from a real failure mode, just not at 50.

Full Changelog: v0.6.4...v0.6.5

v0.6.4

24 Apr 09:33

Choose a tag to compare

v0.6.4 — Tell callers when require_dry_run silently overrides dry_run=false

Fixes #19. If you've ever asked Claude Code or Cursor to apply an AdLoop plan and watched it loop forever — "Applying with dry_run=false… the server is treating the first dry_run=false call as a preview anyway. Re-calling to actually commit…" — this release fixes the root cause. The safety gate was doing the right thing; the error channel was lying about why, and every sane agent retried because the response literally told it to.

The bug

safety.require_dry_run: true is the default written by adloop init (a sensible safety rail for new installs). Inside confirm_and_apply it silently coerced any incoming dry_run=false back to true:

if config.safety.require_dry_run:
    dry_run = True

The returned payload then told the caller:

"Dry run completed — no changes were made to your Google Ads account. To apply for real, call confirm_and_apply again with dry_run=false."

…which was exactly the argument the caller had just passed. That's the retry loop Jesse documented in #19. The LLM's only escape hatch was to invent theories — dashboard toggles, OAuth scopes, read-only accounts — none of which exist. None of the runtime surfaces (tool docstring, response payload, error message) named require_dry_run, pointed at ~/.adloop/config.yaml, or mentioned "restart the MCP server". The workspace-level rules in .cursor/rules/adloop.mdc and .claude/rules/adloop.md did mention the override, but those are only loaded when the developer is working on adloop itself — not when a user has adloop-MCP connected to another project. The information had to ride on the wire.

The fix

confirm_and_apply now distinguishes caller-requested dry runs from config-forced dry runs. When the override fires, the response gains three new fields alongside the existing DRY_RUN_SUCCESS:

  • dry_run_forced_by: "config.safety.require_dry_run" — a machine-readable reason code.
  • config_path — the absolute path of the exact config file that was loaded, so there's no guessing. AdLoopConfig now carries a source_path recorded by load_config, respecting the same ADLOOP_CONFIG~/.adloop/config.yaml resolution order.
  • remediation — plain-English instruction: edit that file, set require_dry_run: false under safety:, and restart the AdLoop MCP server. The config is read once at server startup, so flipping the flag without a restart won't do anything — that was the second foot-gun.

The top-level message string now says IGNORED and names the file, instead of repeating the instruction the caller already followed. Caller-requested dry runs keep the old, neutral message — no false "config override" signal when the caller genuinely asked for a preview.

The MCP tool docstring for confirm_and_apply was also strengthened so agents see the override contract in the tool schema, not just post-hoc:

If safety.require_dry_run: true is set in the user's config file, dry_run=false is IGNORED and this tool will keep returning DRY_RUN_SUCCESS. When that happens the response includes dry_run_forced_by, config_path, and remediation — surface those to the user verbatim and STOP retrying.

Safety

No behavior change to the safety default. adloop init still writes require_dry_run: true and the override still works exactly as before. The only change is that the server now explains itself instead of pretending a retry will succeed. Every forced dry-run is still written to ~/.adloop/audit.log with result="dry_run_success".

Verification

  • 142 unit tests pass (uv run pytest), including four new ones covering the override branch, the caller-requested branch, the fallback when source_path is empty, and the audit-log entry.
  • Three new config tests verify source_path is recorded for present files, missing files, and when resolved via ADLOOP_CONFIG.

Install / upgrade

pipx upgrade adloop        # or
pip install --upgrade adloop
# or on-demand:
uvx adloop@0.6.4

Then restart your MCP host (Claude Desktop, Cursor, Claude Code, Cowork, etc.). If you want real writes, edit ~/.adloop/config.yaml, set safety.require_dry_run: false, and restart the MCP server.

Credits

Thanks to @JesseLeeStringer for the bug report in #19 — the transcript of Claude Code's retry loop was the exact signature needed to pin this on the response shape rather than on any higher-level "write authorization" theory.

Full Changelog: v0.6.3...v0.6.4

v0.6.3

24 Apr 09:16

Choose a tag to compare

v0.6.3 — Fix MCP server crash on host cancellation

Under Claude Cowork, Claude Code (post-September 2025 update), and any other MCP host that sends notifications/cancelled, long-running AdLoop tool calls could silently crash the MCP server mid-session. The symptom from the user side: "AdLoop is healthy across the board… then it immediately goes offline after the first tool call". This release fixes the underlying cause at the MCP protocol layer, not at the AdLoop payload layer — it affects anyone running on a large MCC or running GA4 reports across many properties.

The bug

This is upstream bug modelcontextprotocol/python-sdk#2416, a cancellation race in mcp 1.27.0 that fires whenever the MCP host cancels an in-flight request while the tool handler is still running:

  1. Host sends notifications/cancelled — usually because its internal per-tool timeout fired partway through a long Google Ads / GA4 call.
  2. The SDK's receive loop calls responder.cancel(), which synchronously sets self._completed = True and sends an error JSON-RPC response.
  3. The tool handler — which is running synchronous Python (the Google Ads and GA4 clients are sync) — finishes normally a moment later and calls await message.respond(response).
  4. respond() hits assert not self._completed in mcp/shared/session.py:129AssertionError: Request already responded to.
  5. The assertion escapes the anyio TaskGroup, tears down the stdio transport, and the entire MCP server process exits.
  6. The host auto-respawns it, but from the user's perspective every subsequent tool call "just disconnected".

Large agency MCCs (100+ accounts) and full GA4 property sweeps make this dramatically more likely because those tools run long enough for the host's timeout to fire.

The fix

AdLoop 0.6.3 monkey-patches mcp.shared.session.RequestResponder.respond and .cancel at server startup with guard-based versions that cannot double-send a JSON-RPC response. Both paths check self._completed synchronously before any await, so exactly one side sends exactly one response and the loser silently returns. No more assertion, no more TaskGroup crash, no more silent process death.

The patch is self-removing: at startup it inspects the installed mcp source and skips patching if the racey assert not self._completed line is no longer present. Once upstream #2416 lands, upgrading the dependency is enough to deactivate the workaround — no AdLoop code changes needed.

Implemented in the new src/adloop/_mcp_patches.py module, installed from server.py alongside diagnostics.install().

Also in this release

  • Lightweight health_check — now a single-row probe (SELECT customer.id … LIMIT 1) instead of a full customer_client enumeration. On a 166-account MCC the old path took multiple seconds and returned a large payload for no diagnostic benefit; the new probe is sub-second regardless of account count.
  • list_accounts(limit=50) — accepts a limit parameter with a default of 50, and returns a truncation note + hint when there are more accounts available. Callers that actually need the full list can raise the limit explicitly; most workflows can pass customer_id directly to other tools without enumerating accounts at all.
  • Opt-in diagnostic instrumentation — set ADLOOP_DEBUG=1 and AdLoop emits structured [adloop-debug] event=… lines to stderr covering process start, tool call start/end, periodic heartbeats, signal receipt (SIGTERM/SIGHUP/SIGINT/SIGPIPE), and atexit. Designed specifically to distinguish graceful exits from signal-driven kills when investigating host-side disconnects. Was the instrumentation that pinned down this bug.
  • Roadmap — added "Claude Desktop one-click install" (adloop install claude-desktop and/or a .dxt extension) so Cowork and Desktop users won't have to hand-edit claude_desktop_config.json.

Action for maintainers

src/adloop/_mcp_patches.py is a temporary workaround for python-sdk#2416. Watch the upstream issue. When it closes and a fixed mcp release is pinned, delete src/adloop/_mcp_patches.py and its import/call in src/adloop/server.py. The patch already self-disarms via source inspection, so leaving it in place after an upstream fix is safe but redundant.

Install / upgrade

pipx upgrade adloop        # or
pip install --upgrade adloop
# or run on-demand:
uvx adloop@0.6.3

Then restart your MCP host (Claude Desktop, Cursor, Claude Code, etc.). Tools that previously disconnected mid-call on large MCCs — list_accounts, get_campaign_performance, run_gaql, GA4 reporting across many properties — should now stay connected.

Credits

Bug report and initial reproduction from a Cowork user running a 166-account MCC and 141-property GA4 setup. The detail in that report (exact tool-call sequence + "healthy, then offline" signature) was what pointed at a cancellation race rather than a payload-size issue.

Full Changelog: v0.6.2...v0.6.3

v0.6.2

24 Apr 07:32

Choose a tag to compare

Full Changelog: v0.6.1...v0.6.2

v0.6.1

22 Apr 06:11

Choose a tag to compare

What's Changed

  • Bump workflow actions to Node 24 runtimes by @kLOsk in #16

New Contributors

  • @kLOsk made their first contribution in #16

Full Changelog: v0.6.0...v0.6.1

v0.6.0

19 Apr 16:47

Choose a tag to compare

v0.6.0 — Shared negative keyword list lifecycle

Closes the loop on shared negative keyword lists. Before this release, propose_negative_keyword_list could create a new list, but once it existed there was no way to modify its contents through MCP — every change had to go through the Google Ads UI. The orchestration rules already told the AI to check for existing lists before creating new ones, but that check had no follow-up tool, so the AI would find a matching list and then create a duplicate anyway.

This release ships the three missing pieces so the full create → append → remove lifecycle works end-to-end through the same preview / dry-run / confirm safety layer as every other write tool.

New tools

  • add_to_negative_keyword_list(shared_set_id, keywords, match_type) — appends keywords to an existing SharedSet instead of forcing a new list. Collapses case-insensitive duplicates, rejects whitespace-only entries, and requires a numeric shared_set_id. Returns a preview with a plan_id for confirm_and_apply.
  • remove_entity now accepts entity_type="shared_criterion" — removes a single keyword from a shared list. The entity_id is the composite sharedSetId~criterionId; feed it straight from the new resource_id field below. Still irreversible, still triggers the double-confirmation safety annotation.

Read-side helper

  • get_negative_keyword_list_keywords now returns resource_id on every row ("sharedSetId~criterionId"). Callers pipe it into remove_entity without hand-assembling composite IDs. Mirrors the existing resource_id pattern on get_negative_keywords.

Updated orchestration rules

The negative-keyword pattern in .cursor/rules/adloop.mdc (and the synced .claude/rules/adloop.md) now documents the three-way branch:

  1. Direct campaign negatives (add_negative_keywords) — campaign-specific, no reuse.
  2. Append to an existing shared list (add_to_negative_keyword_list) — use whenever a matching list exists. Call get_negative_keyword_list_keywords first to avoid duplicate-keyword errors at apply time.
  3. Create a new shared list (propose_negative_keyword_list) — only when no suitable list exists yet.

Plus the removal path: remove_entity with entity_type="shared_criterion" and the resource_id from the read tool.

Safety

  • New append tool goes through the standard ChangePlanstore_planconfirm_and_apply pipeline. No bypass.
  • shared_criterion removal inherits the existing "remove" match in requires_double_confirmation, so both confirmations still fire.
  • Apply-time validation rejects a bare entity_id for shared_criterion with a clear error pointing at the correct format, rather than letting an opaque Google Ads API error surface.

Verification

  • 135/135 unit tests passing (16 new).
  • Two new behavioral evals cover: preferring add_to_negative_keyword_list over recreating a duplicate list, and using entity_type="shared_criterion" with an irreversibility warning for removal.
  • End-to-end verified against the live Google Ads API: append + remove round-trip on a real shared set, with resource_id visible on every row.

Also in this release

  • remove_entity docstring in src/adloop/ads/write.py now enumerates every supported entity_type and its composite entity_id format. It had grown stale as shared_criterion, campaign_asset, asset, and customer_asset were added over several releases.
  • Write evals re-ordered to 8 → 9 → 10 so the file scans in id order.

Install / upgrade

uv add adloop          # or
pip install --upgrade adloop

Credits

New tool design, implementation, tests, and live verification by @illia-sapryga in #15 — their first contribution to AdLoop. Welcome!

Full Changelog: v0.5.2...v0.6.0