v0.8.0
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)— createsCampaignSharedSetlinkages 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 afterdraft_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 = FALSEso the positive-geo replacement only sweeps positives. - The preview now surfaces preserved negative geo exclusions via a new
preserved_negative_geo_target_idsfield 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_entitywithentity_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
adwordsscope) - Same developer token + login-customer-id MCC header semantics
- Pagination via
nextPageTokenis followed transparently so response semantics ("all matching ideas") match the previous gRPC iterator - HTTP 429 from REST is surfaced as a
RESOURCE_EXHAUSTEDstring so the existingcall_with_retryexponential-backoff helper still kicks in - All other Ads tools (
run_gaql,get_campaign_performance, everydraft_*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.
Nonepasses 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 initno longer crashes on Windows paths (#30, credit @JesseLeeStringer). The wizard previously wrotecredentials_path: "c:\Users\..."inside double-quoted YAML, which the parser treated as a\UUnicode escape and rejected withScannerError. Single-quoted YAML is now used forcredentials_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 thegoogle-adsSDK at the API version we're pinned to. Avoids the drift between hand-maintained validator sets and the SDK — during development this revealed our hardcodedConversionActionTypeEnumset was missing 29 of 40 valid types.
Closed issues
- #28 —
run_ga4_reportPydantic list validation - #31 —
update_ad_groupmax_cpc error wording - #32 —
update_campaignnegative-geo silent removal - #36 — RSA pinning support (already shipped in v0.6.0; closed as "already implemented")
- #37 —
discover_keywordsgRPC 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_applyflow withdry_run=truedefault
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
- @SullyGitHub — PR #22 (shared-set attach/detach)
- @illia-sapryga — PR #33 (enum introspection helper)
- @JesseLeeStringer — PR #30 (Windows YAML credentials_path)
- @PeterrrPiperrr — issues #31, #32, #36, #37
- @sg-modlab — issue #28
Full Changelog: v0.7.0...v0.8.0