adloop 0.16.0__tar.gz → 0.17.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {adloop-0.16.0 → adloop-0.17.0}/PKG-INFO +5 -1
  2. {adloop-0.16.0 → adloop-0.17.0}/README.md +4 -0
  3. {adloop-0.16.0 → adloop-0.17.0}/pyproject.toml +1 -1
  4. {adloop-0.16.0 → adloop-0.17.0}/pyproject.toml.orig +1 -1
  5. adloop-0.17.0/src/adloop/ads/brands.py +201 -0
  6. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/read.py +14 -2
  7. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/validate_only.py +41 -14
  8. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ga4/reports.py +21 -0
  9. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/adloop.md +5 -1
  10. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/server.py +94 -7
  11. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/__init__.py +0 -0
  12. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/__main__.py +0 -0
  13. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/_mcp_patches.py +0 -0
  14. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/__init__.py +0 -0
  15. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/client.py +0 -0
  16. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/conversion_actions.py +0 -0
  17. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/currency.py +0 -0
  18. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/enums.py +0 -0
  19. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/forecast.py +0 -0
  20. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/gaql.py +0 -0
  21. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/pmax.py +0 -0
  22. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ads/write.py +0 -0
  23. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/auth.py +0 -0
  24. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/cli.py +0 -0
  25. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/config.py +0 -0
  26. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/crossref.py +0 -0
  27. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/diagnostics.py +0 -0
  28. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ga4/__init__.py +0 -0
  29. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ga4/client.py +0 -0
  30. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ga4/tracking.py +0 -0
  31. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/ga4/write.py +0 -0
  32. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gsc/__init__.py +0 -0
  33. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gsc/client.py +0 -0
  34. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gsc/reports.py +0 -0
  35. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gtm/__init__.py +0 -0
  36. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gtm/client.py +0 -0
  37. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/gtm/read.py +0 -0
  38. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/merchant/__init__.py +0 -0
  39. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/merchant/client.py +0 -0
  40. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/merchant/read.py +0 -0
  41. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/pagespeed.py +0 -0
  42. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/__init__.py +0 -0
  43. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/auth.py +0 -0
  44. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/client.py +0 -0
  45. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/read.py +0 -0
  46. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/schedule.py +0 -0
  47. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/reddit/write.py +0 -0
  48. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/__init__.py +0 -0
  49. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  50. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  51. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/create-ad.md +0 -0
  52. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  53. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  54. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  55. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/rules_install.py +0 -0
  56. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/runtime.py +0 -0
  57. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/safety/__init__.py +0 -0
  58. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/safety/audit.py +0 -0
  59. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/safety/guards.py +0 -0
  60. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/safety/preview.py +0 -0
  61. {adloop-0.16.0 → adloop-0.17.0}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -132,8 +132,12 @@ The best features come from real workflows. If you're using AdLoop and find your
132
132
  | `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
133
133
  | `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
134
134
  | `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
135
+ | `suggest_brands` | Resolve a brand name to the brands Google recognizes — brand ID, name, state, URLs |
136
+ | `check_brand_names` | Check a shortlist of brand names against Google's brand knowledge graph (max 25 per call) |
135
137
  | `run_gaql` | Arbitrary GAQL queries for anything else |
136
138
 
139
+ > **Brand targeting** — brand criteria (brand lists, brand exclusions) target a brand's **Commercial Knowledge Graph ID**, not its display name. Use `suggest_brands` for a single name or `check_brand_names` for a shortlist to get the ID; `exact_match` marks a candidate whose name matches apart from case and punctuation, everything else is a Google suggestion.
140
+
137
141
  > **Compact mode** — `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`: account totals, breakdowns, top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword candidates, thin RSAs) instead of every row. ~90% smaller responses — built for account audits so raw tables don't flood your AI's context.
138
142
 
139
143
  ### Cross-Reference Tools (GA4 + Ads Combined)
@@ -105,8 +105,12 @@ The best features come from real workflows. If you're using AdLoop and find your
105
105
  | `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
106
106
  | `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
107
107
  | `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
108
+ | `suggest_brands` | Resolve a brand name to the brands Google recognizes — brand ID, name, state, URLs |
109
+ | `check_brand_names` | Check a shortlist of brand names against Google's brand knowledge graph (max 25 per call) |
108
110
  | `run_gaql` | Arbitrary GAQL queries for anything else |
109
111
 
112
+ > **Brand targeting** — brand criteria (brand lists, brand exclusions) target a brand's **Commercial Knowledge Graph ID**, not its display name. Use `suggest_brands` for a single name or `check_brand_names` for a shortlist to get the ID; `exact_match` marks a candidate whose name matches apart from case and punctuation, everything else is a Google suggestion.
113
+
110
114
  > **Compact mode** — `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`: account totals, breakdowns, top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword candidates, thin RSAs) instead of every row. ~90% smaller responses — built for account audits so raw tables don't flood your AI's context.
111
115
 
112
116
  ### Cross-Reference Tools (GA4 + Ads Combined)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.16.0"
3
+ version = "0.17.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.16.0"
3
+ version = "0.17.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,201 @@
1
+ """Google Ads brand tools — resolve brand names to Google's brand entities.
2
+
3
+ Backs the brand picker of the Google Ads UI: ``BrandSuggestionService``
4
+ matches a free-text name against Google's brand knowledge graph and answers
5
+ with the canonical brand ID, display name, state, and associated URLs.
6
+
7
+ The ID is what brand targeting actually needs. Brand criteria (brand lists /
8
+ ``BRAND_HINT``) reference the Commercial Knowledge Graph ID, not the display
9
+ name — resolving the name first is what makes "steer the account towards
10
+ brand X" possible at all. Read-only: no account state is touched.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ if TYPE_CHECKING:
19
+ from adloop.config import AdLoopConfig
20
+
21
+ # SuggestBrands resolves a single prefix per round trip, and a bulk check is
22
+ # therefore one API call per name. The cap keeps a single tool call inside the
23
+ # MCP host's timeout while covering the usual audit batch (a campaign's brand
24
+ # shortlist, a competitor shortlist, ...). Longer lists are split by the caller.
25
+ MAX_BRAND_BATCH = 25
26
+
27
+ _SENTINEL_STATES = {"UNSPECIFIED", "UNKNOWN"}
28
+
29
+
30
+ def suggest_brands(
31
+ config: AdLoopConfig,
32
+ *,
33
+ brand_prefix: str,
34
+ selected_brand_ids: list[str] | None = None,
35
+ customer_id: str = "",
36
+ ) -> dict:
37
+ """Resolve one brand name to the brands Google recognizes for it.
38
+
39
+ ``selected_brand_ids`` mirrors the API field of the same purpose: IDs the
40
+ caller already picked can be handed back so Google keeps them in the
41
+ suggestion set while the prefix narrows.
42
+ """
43
+ prefix = (brand_prefix or "").strip()
44
+ if not prefix:
45
+ return {"error": "brand_prefix is required — pass the brand name to look up."}
46
+
47
+ cid = customer_id or config.ads.customer_id
48
+ brands = _fetch_brand_suggestions(
49
+ config,
50
+ customer_id=cid,
51
+ brand_prefix=prefix,
52
+ selected_brand_ids=selected_brand_ids or [],
53
+ )
54
+ return {
55
+ "customer_id": _digits(cid),
56
+ "brand_prefix": prefix,
57
+ "brand_count": len(brands),
58
+ "brands": brands,
59
+ }
60
+
61
+
62
+ def check_brand_names(
63
+ config: AdLoopConfig,
64
+ *,
65
+ brand_names: list[str],
66
+ customer_id: str = "",
67
+ ) -> dict:
68
+ """Check several brand names against Google's brand knowledge graph.
69
+
70
+ One ``SuggestBrands`` call per name. Names Google does not know come back
71
+ as ``status: "no_match"`` with an empty candidate list — that is a normal
72
+ answer, not an error. An API failure aborts the whole batch and surfaces
73
+ as the regular structured error, so an expired token cannot hide behind a
74
+ half-filled result list.
75
+ """
76
+ names = _clean_brand_names(brand_names)
77
+ if not names:
78
+ return {"error": "brand_names is required — pass the brand names to check."}
79
+ if len(names) > MAX_BRAND_BATCH:
80
+ return {
81
+ "error": (
82
+ f"Too many brand names in one call ({len(names)}). "
83
+ f"SuggestBrands answers one prefix per request — split into "
84
+ f"batches of at most {MAX_BRAND_BATCH}."
85
+ )
86
+ }
87
+
88
+ cid = customer_id or config.ads.customer_id
89
+ results: list[dict] = []
90
+ for name in names:
91
+ brands = _fetch_brand_suggestions(
92
+ config, customer_id=cid, brand_prefix=name, selected_brand_ids=[]
93
+ )
94
+ best, exact = _best_match(name, brands)
95
+ results.append(
96
+ {
97
+ "query": name,
98
+ "status": "matched" if best else "no_match",
99
+ "exact_match": exact,
100
+ "brand": best,
101
+ "candidates": brands,
102
+ }
103
+ )
104
+
105
+ matched = sum(1 for r in results if r["status"] == "matched")
106
+ return {
107
+ "customer_id": _digits(cid),
108
+ "checked": len(results),
109
+ "matched": matched,
110
+ "no_match": len(results) - matched,
111
+ "results": results,
112
+ "note": (
113
+ "brand.id is the Commercial Knowledge Graph ID that brand "
114
+ "criteria (brand lists / BRAND_HINT) target. Match exact names "
115
+ "first; candidates are Google's own suggestions, not guarantees."
116
+ ),
117
+ }
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Internals
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def _fetch_brand_suggestions(
126
+ config: AdLoopConfig,
127
+ *,
128
+ customer_id: str,
129
+ brand_prefix: str,
130
+ selected_brand_ids: list[str],
131
+ ) -> list[dict[str, Any]]:
132
+ """One ``BrandSuggestionService.SuggestBrands`` round trip, flattened."""
133
+ from adloop.ads.client import call_with_retry, get_ads_client, normalize_customer_id
134
+
135
+ client = get_ads_client(config)
136
+ service = client.get_service("BrandSuggestionService")
137
+ request = client.get_type("SuggestBrandsRequest")
138
+ request.customer_id = normalize_customer_id(customer_id)
139
+ request.brand_prefix = brand_prefix
140
+ for brand_id in selected_brand_ids:
141
+ request.selected_brands.append(str(brand_id))
142
+
143
+ response = call_with_retry(service.suggest_brands, request=request)
144
+
145
+ brand_state = client.enums.BrandStateEnum
146
+ brands: list[dict[str, Any]] = []
147
+ for suggestion in response.brands:
148
+ state = brand_state(suggestion.state).name
149
+ brands.append(
150
+ {
151
+ "id": suggestion.id,
152
+ "name": suggestion.name,
153
+ "state": state,
154
+ "urls": list(suggestion.urls),
155
+ }
156
+ )
157
+ return brands
158
+
159
+
160
+ def _clean_brand_names(brand_names: list[str] | None) -> list[str]:
161
+ """Trim, drop blanks, and de-duplicate while keeping the caller's order."""
162
+ seen: set[str] = set()
163
+ names: list[str] = []
164
+ for raw in brand_names or []:
165
+ name = (raw or "").strip()
166
+ if not name or name.casefold() in seen:
167
+ continue
168
+ seen.add(name.casefold())
169
+ names.append(name)
170
+ return names
171
+
172
+
173
+ def _best_match(query: str, brands: list[dict[str, Any]]) -> tuple[dict | None, bool]:
174
+ """Pick the suggestion that best answers *query*.
175
+
176
+ Ranking is deliberately shallow: an exact name match wins, then brands in
177
+ a usable state, then Google's own order. Anything deeper would imply a
178
+ confidence the API does not provide.
179
+ """
180
+ if not brands:
181
+ return None, False
182
+
183
+ normalized = _normalize(query)
184
+ ranked = sorted(
185
+ brands,
186
+ key=lambda brand: (
187
+ 0 if _normalize(brand["name"]) == normalized else 1,
188
+ 0 if brand["state"] not in _SENTINEL_STATES else 1,
189
+ ),
190
+ )
191
+ best = ranked[0]
192
+ return best, _normalize(best["name"]) == normalized
193
+
194
+
195
+ def _normalize(name: str) -> str:
196
+ """Case- and punctuation-insensitive form used for exact-match checks."""
197
+ return re.sub(r"[\W_]+", "", (name or "").casefold())
198
+
199
+
200
+ def _digits(customer_id: str) -> str:
201
+ return (customer_id or "").replace("-", "")
@@ -190,6 +190,7 @@ def get_ad_performance(
190
190
  "ad_group": r.get("ad_group.name"),
191
191
  "headlines": headlines,
192
192
  "descriptions": descriptions,
193
+ "final_urls": r.get("ad_group_ad.ad.final_urls") or [],
193
194
  })
194
195
 
195
196
  single_ad_groups = [
@@ -205,7 +206,6 @@ def get_ad_performance(
205
206
  if k not in (
206
207
  "ad_group_ad.ad.responsive_search_ad.headlines",
207
208
  "ad_group_ad.ad.responsive_search_ad.descriptions",
208
- "ad_group_ad.ad.final_urls",
209
209
  )
210
210
  }
211
211
  slim["headline_count"] = _asset_count(
@@ -228,6 +228,12 @@ def get_ad_performance(
228
228
  f"Google cannot rotate or optimize with a single creative."
229
229
  )
230
230
 
231
+ # Landing-page work needs every ad's URL, not just the top ten rows'.
232
+ landing_pages: dict[str, int] = {}
233
+ for r in rows:
234
+ for url in r.get("ad_group_ad.ad.final_urls") or []:
235
+ landing_pages[url] = landing_pages.get(url, 0) + 1
236
+
231
237
  top = [_slim(r) for r in rows[:10]]
232
238
  return {
233
239
  "compact": True,
@@ -237,9 +243,14 @@ def get_ad_performance(
237
243
  "ads_top_spend": top,
238
244
  "incomplete_rsas": incomplete_rsas[:10],
239
245
  "single_ad_ad_groups": single_ad_groups[:10],
246
+ "landing_pages": [
247
+ {"final_url": url, "ads": count}
248
+ for url, count in sorted(landing_pages.items(), key=lambda item: -item[1])
249
+ ],
240
250
  "insights": insights,
241
251
  "note": _compact_note(len(top), len(rows), "get_ad_performance")
242
- + " Compact rows replace full headline/description lists with counts.",
252
+ + " Compact rows replace full headline/description lists with counts;"
253
+ " landing_pages lists every final URL across all ads.",
243
254
  }
244
255
 
245
256
 
@@ -480,6 +491,7 @@ def get_negative_keywords(
480
491
  campaign_criterion.criterion_id
481
492
  FROM campaign_criterion
482
493
  WHERE campaign_criterion.negative = TRUE
494
+ AND campaign_criterion.type = 'KEYWORD'
483
495
  AND campaign_criterion.status != 'REMOVED'
484
496
  {campaign_filter}
485
497
  ORDER BY campaign.name
@@ -10,6 +10,11 @@ Validate-only responses carry no results, so each validated call answers the
10
10
  apply code with placeholder resource names. A later call that references one
11
11
  (a plan whose second step uses what the first would have created) cannot be
12
12
  validated meaningfully; it is skipped and counted instead of sent.
13
+
14
+ The wrapper fails closed: only path helpers, reads, ``mutate*`` and
15
+ ``upload*`` calls (both sent validate-only) reach the real service. Any other
16
+ method raises, so a write path added later cannot slip past a dry run and
17
+ change an account for real.
13
18
  """
14
19
 
15
20
  from __future__ import annotations
@@ -18,7 +23,8 @@ from types import SimpleNamespace
18
23
 
19
24
  PLACEHOLDER = "adloop-validate-only"
20
25
 
21
- _RESPONSE_FIELD_FOR_SERVICE = "campaign_result"
26
+ # Methods that never change an account and may run for real in a dry run.
27
+ _READ_METHODS = frozenset({"search", "search_stream"})
22
28
 
23
29
 
24
30
  class ValidateOnlyFailure(Exception):
@@ -52,9 +58,14 @@ class _ValidateOnlyService:
52
58
  self._service = service
53
59
 
54
60
  def __getattr__(self, attr: str) -> object:
61
+ if attr in _READ_METHODS or attr.endswith("_path") or attr.startswith("parse_"):
62
+ return getattr(self._service, attr)
63
+ if not (attr.startswith("mutate") or attr.startswith("upload")):
64
+ raise ValidateOnlyFailure(
65
+ f"{self._name}.{attr} has no validate-only mode, so a dry run "
66
+ "cannot check it without changing the account. Refusing."
67
+ )
55
68
  target = getattr(self._service, attr)
56
- if not attr.startswith("mutate"):
57
- return target
58
69
 
59
70
  def validate(request: object = None, **kwargs: object) -> object:
60
71
  if request is None:
@@ -78,35 +89,51 @@ class _ValidateOnlyService:
78
89
  def _build_request(self, method: str, kwargs: dict) -> object:
79
90
  request = self._owner._client.get_type(_request_type(self._name, method))
80
91
  for key, value in kwargs.items():
81
- if key in ("operations", "mutate_operations"):
92
+ if key in _LIST_FIELDS:
82
93
  getattr(request, key).extend(value)
83
94
  else:
84
95
  setattr(request, key, value)
85
96
  return request
86
97
 
87
98
 
99
+ _LIST_FIELDS = ("operations", "mutate_operations", "conversions")
100
+
101
+
88
102
  def _request_type(service_name: str, method: str) -> str:
89
- """``mutate_ad_group_criteria`` -> ``MutateAdGroupCriteriaRequest``."""
103
+ """``mutate_ad_group_criteria`` -> ``MutateAdGroupCriteriaRequest``,
104
+ ``upload_click_conversions`` -> ``UploadClickConversionsRequest``."""
90
105
  if method == "mutate":
91
106
  return "Mutate" + service_name.removesuffix("Service") + "Request"
92
- words = method.removeprefix("mutate_").split("_")
93
- return "Mutate" + "".join(word.capitalize() for word in words) + "Request"
107
+ verb, _, rest = method.partition("_")
108
+ return verb.capitalize() + "".join(word.capitalize() for word in rest.split("_")) + "Request"
94
109
 
95
110
 
96
111
  def _operations_of(request: object) -> object:
97
- if hasattr(request, "mutate_operations"):
98
- return request.mutate_operations
99
- return getattr(request, "operations", [])
112
+ for field in _LIST_FIELDS:
113
+ if hasattr(request, field):
114
+ return getattr(request, field)
115
+ return []
116
+
117
+
118
+ class _PlaceholderResult:
119
+ """A MutateOperationResponse stand-in: every ``*_result`` field
120
+ (``asset_result``, ``campaign_asset_result``, ...) carries the placeholder,
121
+ whichever one the apply code reads."""
122
+
123
+ def __init__(self, name: str) -> None:
124
+ self._name = name
125
+
126
+ def __getattr__(self, attr: str) -> object:
127
+ if attr.endswith("_result"):
128
+ return SimpleNamespace(resource_name=self._name)
129
+ raise AttributeError(attr)
100
130
 
101
131
 
102
132
  def _placeholder_response(customer_id: str, count: int, googleads_mutate: bool) -> object:
103
133
  names = [f"customers/{customer_id}/{PLACEHOLDER}/{i}" for i in range(count)]
104
134
  if googleads_mutate:
105
135
  return SimpleNamespace(
106
- mutate_operation_responses=[
107
- SimpleNamespace(**{_RESPONSE_FIELD_FOR_SERVICE: SimpleNamespace(resource_name=n)})
108
- for n in names
109
- ],
136
+ mutate_operation_responses=[_PlaceholderResult(n) for n in names],
110
137
  partial_failure_error=None,
111
138
  )
112
139
  return SimpleNamespace(
@@ -36,6 +36,27 @@ def get_account_summaries(config: AdLoopConfig) -> dict:
36
36
  }
37
37
 
38
38
 
39
+ def first_property(summaries: dict) -> str:
40
+ """The first property in a get_account_summaries() result, or ""."""
41
+ for account in summaries.get("accounts", []):
42
+ for prop in account.get("properties", []):
43
+ if prop.get("property"):
44
+ return prop["property"]
45
+ return ""
46
+
47
+
48
+ def probe_data_api(config: AdLoopConfig, property_name: str) -> None:
49
+ """Raise if the GA4 Data API cannot serve this property.
50
+
51
+ A metadata lookup is the cheapest Data API call: it touches no report
52
+ quota, yet fails with SERVICE_DISABLED exactly when every report would.
53
+ """
54
+ from adloop.ga4.client import get_data_client
55
+
56
+ name = property_name if property_name.startswith("properties/") else f"properties/{property_name}"
57
+ get_data_client(config).get_metadata(name=f"{name}/metadata")
58
+
59
+
39
60
  def run_ga4_report(
40
61
  config: AdLoopConfig,
41
62
  *,
@@ -14,6 +14,8 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
14
14
  |------|-------------|----------------|
15
15
  | `health_check` | First thing to run when tools are failing — tests OAuth token, GA4, Ads, and (when configured) Reddit Ads connectivity | (none) |
16
16
 
17
+ **GA4 has two surfaces in health_check:** `ga4_admin` (listing properties) and `ga4_data` (every report). `ga4_data: error` with `SERVICE_DISABLED` means the Google Analytics Data API is not enabled in the Google Cloud project — tell the user to enable it; it is not a permissions or consent problem.
18
+
17
19
  **If health_check reports Google auth errors:** Tell the user to delete `~/.adloop/token.json` and re-run any tool to trigger re-authorization. If tokens keep expiring weekly, the GCP consent screen needs to be published from "Testing" to "In production". **Reddit auth errors** (`REDDIT_INVALID_GRANT`, `REDDIT_NOT_CONNECTED`) are fixed by re-running the Reddit step of `adloop init` (self-hosted) or reconnecting under Settings → Reddit Ads (AdLoop Cloud); the Google token is unrelated.
18
20
 
19
21
  ### GA4 Read Tools
@@ -44,6 +46,8 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
44
46
  | `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most | `campaign_id` (optional) |
45
47
  | `get_audience_performance` | Audience segment metrics — remarketing, in-market, affinity, demographics | `date_range_start`, `date_range_end`, `campaign_id` (optional) |
46
48
  | `get_demographic_targeting` | List current demographic criteria (age/gender/parental status/income) on an ad group or campaign — returns each criterion's `remove_id` for use with `remove_entity` | exactly one of `ad_group_id` or `campaign_id` |
49
+ | `suggest_brands` | Resolve one brand name to the brands Google recognizes — brand ID, name, state, URLs. The ID is what brand criteria target, so resolve here before building any brand list | `brand_prefix` (required), `selected_brand_ids` (optional, IDs already picked) |
50
+ | `check_brand_names` | Triage a shortlist of brand names at once: matched vs. unknown, plus each brand's ID | `brand_names` (required, max 25 per call) |
47
51
  | `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) |
48
52
 
49
53
  **Return format notes:**
@@ -55,7 +59,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
55
59
  - `get_asset_performance` returns `by_status` and `by_field_type` summaries. Note: per-asset performance labels (BEST/GOOD/LOW) are not available for PMax assets in the Google Ads API. Use `get_detailed_asset_performance` for quality signals via top combinations.
56
60
  - `get_audience_performance` works for campaigns with explicit audience targeting. PMax audience targeting is automatic and may not appear in this report. When the results include SEARCH campaigns, `insights[]` reminds you that custom segments cannot be attached to them (see the compatibility matrix below) — relay that constraint instead of proposing impossible pairings.
57
61
  - `get_demographic_targeting` returns an empty list when no demographics have been excluded or narrowed — that is the DEFAULT state (Google serves to all segments). Each criterion includes a composite `remove_id` (`adGroupId~criterionId` or `campaignId~criterionId`) that can be passed straight to `remove_entity`.
58
- - **Compact mode for audits**: `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`, returning account totals, breakdowns, the top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword waste candidates, thin RSAs, single-ad ad groups) instead of every row — roughly 90% smaller. Use it for account audits and overviews so raw tables don't flood the context; use the default full mode when you need a specific entity's exact rows before drafting a change. In harnesses with subagents, heavy multi-tool audits can additionally be delegated to a subagent that returns only the summary.
62
+ - **Compact mode for audits**: `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`, returning account totals, breakdowns, the top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword waste candidates, thin RSAs, single-ad ad groups) instead of every row; compact `get_ad_performance` keeps `final_urls` on its rows and adds `landing_pages` (every final URL across all ads with its ad count), so landing-page checks need no extra GAQL — roughly 90% smaller. Use it for account audits and overviews so raw tables don't flood the context; use the default full mode when you need a specific entity's exact rows before drafting a change. In harnesses with subagents, heavy multi-tool audits can additionally be delegated to a subagent that returns only the summary.
59
63
 
60
64
  ### Cross-Reference Tools (GA4 + Ads combined)
61
65
 
@@ -421,15 +421,10 @@ def health_check() -> dict:
421
421
  except ImportError:
422
422
  pass
423
423
 
424
- try:
425
- from adloop.ga4.reports import get_account_summaries as _ga4_test
426
-
427
- result = _ga4_test(current_config())
428
- status["ga4"] = "ok"
429
- status["ga4_properties"] = result.get("total_properties", 0)
430
- except Exception as e:
424
+ def _ga4_failed(surface: str, e: Exception) -> None:
431
425
  parsed = _structured_error("health_check", e)
432
426
  status["ga4"] = "error"
427
+ status[surface] = "error"
433
428
  status["ga4_error"] = parsed["error"]
434
429
  if "hint" in parsed:
435
430
  status["ga4_hint"] = parsed["hint"]
@@ -438,6 +433,32 @@ def health_check() -> dict:
438
433
  if "details" in parsed:
439
434
  status["ga4_error_details"] = parsed["details"]
440
435
 
436
+ # Two surfaces: the Admin API lists properties, the Data API serves every
437
+ # report. A project can have one enabled and not the other, so "ok" means
438
+ # both answered.
439
+ try:
440
+ from adloop.ga4.reports import get_account_summaries as _ga4_test
441
+
442
+ result = _ga4_test(current_config())
443
+ status["ga4_admin"] = "ok"
444
+ status["ga4_properties"] = result.get("total_properties", 0)
445
+ except Exception as e:
446
+ _ga4_failed("ga4_admin", e)
447
+ else:
448
+ from adloop.ga4.reports import first_property, probe_data_api
449
+
450
+ prop = current_config().ga4.property_id or first_property(result)
451
+ if not prop:
452
+ status["ga4"] = "ok"
453
+ status["ga4_data"] = "not_checked"
454
+ else:
455
+ try:
456
+ probe_data_api(current_config(), prop)
457
+ status["ga4"] = "ok"
458
+ status["ga4_data"] = "ok"
459
+ except Exception as e:
460
+ _ga4_failed("ga4_data", e)
461
+
441
462
  try:
442
463
  from adloop.ads.gaql import execute_query
443
464
 
@@ -3291,6 +3312,72 @@ def discover_keywords(
3291
3312
  )
3292
3313
 
3293
3314
 
3315
+ # ---------------------------------------------------------------------------
3316
+ # Google Ads — Brand Tools
3317
+ # ---------------------------------------------------------------------------
3318
+
3319
+
3320
+ @mcp.tool(title="Suggest brands", annotations=_READONLY, tags={"ads"})
3321
+ @_safe
3322
+ def suggest_brands(
3323
+ brand_prefix: str,
3324
+ selected_brand_ids: _StrList = [], # noqa: B006 — mutable default required for MCP JSON schema
3325
+ customer_id: str = "",
3326
+ ) -> dict:
3327
+ """Resolve a brand name to the brands Google recognizes for it.
3328
+
3329
+ Mirrors the brand picker in the Google Ads UI
3330
+ (BrandSuggestionService.SuggestBrands): pass a free-text name such as
3331
+ "EscapeGame München" or "NoWayOut" and get back the matching brands with
3332
+ their ID, display name, state, and associated URLs.
3333
+
3334
+ The returned brand ID is the Commercial Knowledge Graph ID. Brand
3335
+ criteria — brand lists, brand exclusions — target that ID, not a display
3336
+ name, so resolve the name here before building any brand list.
3337
+
3338
+ selected_brand_ids: IDs already picked, handed back so Google keeps them
3339
+ in the suggestion set while the prefix narrows. Optional.
3340
+ """
3341
+ from adloop.ads.brands import suggest_brands as _impl
3342
+
3343
+ return _impl(
3344
+ current_config(),
3345
+ brand_prefix=brand_prefix,
3346
+ selected_brand_ids=selected_brand_ids,
3347
+ customer_id=customer_id or current_config().ads.customer_id,
3348
+ )
3349
+
3350
+
3351
+ @mcp.tool(title="Check brand names", annotations=_READONLY, tags={"ads"})
3352
+ @_safe
3353
+ def check_brand_names(
3354
+ brand_names: _StrList,
3355
+ customer_id: str = "",
3356
+ ) -> dict:
3357
+ """Check a list of brand names against Google's brand knowledge graph.
3358
+
3359
+ One SuggestBrands call per name, for the case where a shortlist has to be
3360
+ triaged instead of a single name resolved: which of these brands does
3361
+ Google know at all, and what is the ID of each?
3362
+
3363
+ Every entry answers with status "matched" (with a best-match brand plus
3364
+ all candidates) or "no_match" (empty candidate list — a normal answer,
3365
+ not an error). exact_match marks a candidate whose name matches the query
3366
+ apart from case and punctuation; anything else is a Google suggestion,
3367
+ not a guarantee.
3368
+
3369
+ At most 25 names per call — the API resolves one prefix per request, so
3370
+ split longer lists.
3371
+ """
3372
+ from adloop.ads.brands import check_brand_names as _impl
3373
+
3374
+ return _impl(
3375
+ current_config(),
3376
+ brand_names=brand_names,
3377
+ customer_id=customer_id or current_config().ads.customer_id,
3378
+ )
3379
+
3380
+
3294
3381
  # ---------------------------------------------------------------------------
3295
3382
  # Optional local-only debug tools (not shipped in git).
3296
3383
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes