adloop 0.13.2__tar.gz → 0.13.3__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 (52) hide show
  1. {adloop-0.13.2 → adloop-0.13.3}/PKG-INFO +7 -5
  2. {adloop-0.13.2 → adloop-0.13.3}/README.md +6 -4
  3. {adloop-0.13.2 → adloop-0.13.3}/pyproject.toml +1 -1
  4. {adloop-0.13.2 → adloop-0.13.3}/pyproject.toml.orig +1 -1
  5. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ads/read.py +46 -4
  6. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/config.py +32 -12
  7. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/rules/adloop.md +23 -10
  8. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/server.py +103 -67
  9. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/__init__.py +0 -0
  10. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/__main__.py +0 -0
  11. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/_mcp_patches.py +0 -0
  12. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ads/__init__.py +0 -0
  13. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ads/client.py +0 -0
  14. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ads/currency.py +0 -0
  15. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ads/enums.py +0 -0
  16. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ads/forecast.py +0 -0
  17. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ads/gaql.py +0 -0
  18. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ads/pmax.py +0 -0
  19. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ads/write.py +0 -0
  20. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/auth.py +0 -0
  21. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/cli.py +0 -0
  22. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/crossref.py +0 -0
  23. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/diagnostics.py +0 -0
  24. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ga4/__init__.py +0 -0
  25. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ga4/client.py +0 -0
  26. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ga4/reports.py +0 -0
  27. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ga4/tracking.py +0 -0
  28. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/ga4/write.py +0 -0
  29. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/gsc/__init__.py +0 -0
  30. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/gsc/client.py +0 -0
  31. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/gsc/reports.py +0 -0
  32. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/gtm/__init__.py +0 -0
  33. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/gtm/client.py +0 -0
  34. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/gtm/read.py +0 -0
  35. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/merchant/__init__.py +0 -0
  36. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/merchant/client.py +0 -0
  37. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/merchant/read.py +0 -0
  38. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/pagespeed.py +0 -0
  39. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/rules/__init__.py +0 -0
  40. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/rules/commands/analyze-performance.md +0 -0
  41. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/rules/commands/budget-plan.md +0 -0
  42. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/rules/commands/create-ad.md +0 -0
  43. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/rules/commands/create-campaign.md +0 -0
  44. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  45. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  46. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/rules_install.py +0 -0
  47. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/runtime.py +0 -0
  48. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/safety/__init__.py +0 -0
  49. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/safety/audit.py +0 -0
  50. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/safety/guards.py +0 -0
  51. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/safety/preview.py +0 -0
  52. {adloop-0.13.2 → adloop-0.13.3}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.13.2
3
+ Version: 0.13.3
4
4
  Summary: The AI command center for Google Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -24,6 +24,8 @@ Project-URL: Changelog, https://github.com/kLOsk/adloop/releases
24
24
  Provides-Extra: dev
25
25
  Description-Content-Type: text/markdown
26
26
 
27
+ <!-- mcp-name: com.getadloop/adloop -->
28
+
27
29
  <div align="center">
28
30
 
29
31
  # AdLoop
@@ -40,12 +42,12 @@ Description-Content-Type: text/markdown
40
42
 
41
43
  An MCP server that gives your AI assistant read + write access to Google Ads and GA4 — with safety guardrails that prevent accidental spend.
42
44
 
43
- **[☁️ Skip the setup — use AdLoop Cloud (free beta)](https://getadloop.com)** &nbsp;·&nbsp; or self-host: `pip install adloop`
45
+ **[☁️ Skip the setup — use AdLoop Cloud (free plan, no card)](https://getadloop.com)** &nbsp;·&nbsp; or self-host: `pip install adloop`
44
46
 
45
47
  </div>
46
48
 
47
49
  > [!TIP]
48
- > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
50
+ > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project, with a free plan that needs no credit card.** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
49
51
 
50
52
  > 📚 **Documentation: [docs.getadloop.com](https://docs.getadloop.com)** — setup guides per AI client, toolsets, the safety model, and troubleshooting for both editions.
51
53
 
@@ -63,7 +65,7 @@ Both versions run the same tools with the same safety model. The difference is w
63
65
  | **Works with** | claude.ai, ChatGPT, Claude Code, Cursor, Gemini | Claude Code, Cursor, Claude Desktop, any local MCP client |
64
66
  | **Where your data flows** | EU servers (Germany), GDPR-first, DPA included | 100% your machine — nothing leaves it |
65
67
  | **Updates** | Automatic | `pip install -U adloop` |
66
- | **Price** | Free during beta | Free forever (MIT) |
68
+ | **Price** | Free plan, no card; [paid plans](https://getadloop.com/preise) for more accounts and volume | Free forever (MIT) |
67
69
 
68
70
  **Not sure? [Start with Cloud](https://getadloop.com)** — it's the fastest way to see what AdLoop can do, and it's the only way to use AdLoop from claude.ai or ChatGPT. Self-host when you want everything on your own machine or need to modify the code. And if you're here to hack on AdLoop itself: welcome, keep scrolling.
69
71
 
@@ -517,7 +519,7 @@ What's been shipped and what's next:
517
519
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
518
520
  - **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
519
521
  - ~~PyPI package~~ ✓ — `pip install adloop`
520
- - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version, live in beta: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
522
+ - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
521
523
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
522
524
  - ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
523
525
  - ~~Google Tag Manager integration~~ ✓ — read tools for tags, triggers, variables, workspaces, and version history, plus the `audit_event_coverage` three-way join across codebase events, GTM tags, and GA4 actual fires
@@ -1,3 +1,5 @@
1
+ <!-- mcp-name: com.getadloop/adloop -->
2
+
1
3
  <div align="center">
2
4
 
3
5
  # AdLoop
@@ -14,12 +16,12 @@
14
16
 
15
17
  An MCP server that gives your AI assistant read + write access to Google Ads and GA4 — with safety guardrails that prevent accidental spend.
16
18
 
17
- **[☁️ Skip the setup — use AdLoop Cloud (free beta)](https://getadloop.com)** &nbsp;·&nbsp; or self-host: `pip install adloop`
19
+ **[☁️ Skip the setup — use AdLoop Cloud (free plan, no card)](https://getadloop.com)** &nbsp;·&nbsp; or self-host: `pip install adloop`
18
20
 
19
21
  </div>
20
22
 
21
23
  > [!TIP]
22
- > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
24
+ > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project, with a free plan that needs no credit card.** Connect Google in two clicks and use the full toolset from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
23
25
 
24
26
  > 📚 **Documentation: [docs.getadloop.com](https://docs.getadloop.com)** — setup guides per AI client, toolsets, the safety model, and troubleshooting for both editions.
25
27
 
@@ -37,7 +39,7 @@ Both versions run the same tools with the same safety model. The difference is w
37
39
  | **Works with** | claude.ai, ChatGPT, Claude Code, Cursor, Gemini | Claude Code, Cursor, Claude Desktop, any local MCP client |
38
40
  | **Where your data flows** | EU servers (Germany), GDPR-first, DPA included | 100% your machine — nothing leaves it |
39
41
  | **Updates** | Automatic | `pip install -U adloop` |
40
- | **Price** | Free during beta | Free forever (MIT) |
42
+ | **Price** | Free plan, no card; [paid plans](https://getadloop.com/preise) for more accounts and volume | Free forever (MIT) |
41
43
 
42
44
  **Not sure? [Start with Cloud](https://getadloop.com)** — it's the fastest way to see what AdLoop can do, and it's the only way to use AdLoop from claude.ai or ChatGPT. Self-host when you want everything on your own machine or need to modify the code. And if you're here to hack on AdLoop itself: welcome, keep scrolling.
43
45
 
@@ -491,7 +493,7 @@ What's been shipped and what's next:
491
493
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
492
494
  - **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
493
495
  - ~~PyPI package~~ ✓ — `pip install adloop`
494
- - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version, live in beta: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
496
+ - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
495
497
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
496
498
  - ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
497
499
  - ~~Google Tag Manager integration~~ ✓ — read tools for tags, triggers, variables, workspaces, and version history, plus the `audit_event_coverage` three-way join across codebase events, GTM tags, and GA4 actual fires
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.13.2"
3
+ version = "0.13.3"
4
4
  description = "The AI command center for Google 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.13.2"
3
+ version = "0.13.3"
4
4
  description = "The AI command center for Google Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -278,10 +278,24 @@ def get_keyword_performance(
278
278
  return {"keywords": rows, "total_keywords": len(rows)}
279
279
 
280
280
  qs_field = "ad_group_criterion.quality_info.quality_score"
281
+
282
+ def _rated_score(r: dict) -> int | None:
283
+ """The Quality Score, or None when Google has not assigned one.
284
+
285
+ Google never issues a real score of 0: both null and 0 mean "not
286
+ enough impressions to rate". Counting those as "< 5" reports an
287
+ ordinary unrated long tail as an account-wide relevance problem and
288
+ sends the assistant off fixing ads and landing pages that are fine.
289
+ """
290
+ score = r.get(qs_field)
291
+
292
+ return score if isinstance(score, int) and score > 0 else None
293
+
281
294
  low_quality = [
282
295
  r for r in rows
283
- if r.get(qs_field) is not None and (r.get(qs_field) or 0) < 5
296
+ if (score := _rated_score(r)) is not None and score < 5
284
297
  ]
298
+ unrated_count = sum(1 for r in rows if _rated_score(r) is None)
285
299
  zero_conv_spenders = [
286
300
  r for r in rows
287
301
  if (r.get("metrics.cost_micros") or 0) > 0
@@ -304,6 +318,11 @@ def get_keyword_performance(
304
318
  f"{len(low_quality)} keyword(s) have quality score < 5 — fix ad "
305
319
  f"relevance and landing pages before adding keywords or budget."
306
320
  )
321
+ if unrated_count:
322
+ insights.append(
323
+ f"{unrated_count} keyword(s) have no quality score yet (too few "
324
+ f"impressions to rate). They are excluded from the count above."
325
+ )
307
326
  if zero_conv_spenders:
308
327
  wasted = round(
309
328
  sum((r.get("metrics.cost_micros") or 0) for r in zero_conv_spenders)
@@ -325,6 +344,7 @@ def get_keyword_performance(
325
344
  ),
326
345
  "keywords_top_spend": top,
327
346
  "low_quality_score": [_kw(r) for r in low_quality[:10]],
347
+ "unrated_quality_score": unrated_count,
328
348
  "zero_conversion_spenders": [_kw(r) for r in zero_conv_spenders[:10]],
329
349
  "insights": insights,
330
350
  "note": _compact_note(len(top), len(rows), "get_keyword_performance"),
@@ -430,7 +450,7 @@ def get_search_terms(
430
450
  return {
431
451
  "compact": True,
432
452
  "total_search_terms": len(rows),
433
- "totals": _compact_totals(rows, currency_code),
453
+ "totals": _compact_totals(rows, currency_code, row_limit=200),
434
454
  "search_terms_top_clicks": top,
435
455
  "waste_candidates": [_term(r) for r in waste[:10]],
436
456
  "top_converters": [_term(r) for r in converters[:5]],
@@ -923,8 +943,15 @@ def _date_clause(start: str, end: str) -> str:
923
943
  return "AND segments.date DURING LAST_30_DAYS"
924
944
 
925
945
 
926
- def _compact_totals(rows: list[dict], currency_code: str) -> dict:
927
- """Deterministic account-level aggregates over enriched metric rows."""
946
+ def _compact_totals(
947
+ rows: list[dict], currency_code: str, *, row_limit: int | None = None
948
+ ) -> dict:
949
+ """Deterministic aggregates over the rows given.
950
+
951
+ Not account-level whenever the query behind them carries a LIMIT: pass
952
+ ``row_limit`` for those reports so a truncated total is marked partial
953
+ instead of being read as the account's real cost and conversions.
954
+ """
928
955
  cost = sum((r.get("metrics.cost_micros") or 0) for r in rows) / 1_000_000
929
956
  clicks = sum((r.get("metrics.clicks") or 0) for r in rows)
930
957
  impressions = sum((r.get("metrics.impressions") or 0) for r in rows)
@@ -940,6 +967,21 @@ def _compact_totals(rows: list[dict], currency_code: str) -> dict:
940
967
  totals["cpa"] = round(cost / conversions, 2)
941
968
  if impressions > 0:
942
969
  totals["ctr_pct"] = round(clicks / impressions * 100, 2)
970
+
971
+ # Hitting the ceiling exactly is indistinguishable from stopping there, so
972
+ # this errs toward declaring partial. Silently under-reporting cost is the
973
+ # worse failure: these numbers are labelled "totals" and get compared
974
+ # against campaign-level truth.
975
+ if row_limit is not None and len(rows) >= row_limit:
976
+ totals["partial"] = True
977
+ totals["rows_counted"] = len(rows)
978
+ totals["partial_reason"] = (
979
+ f"The underlying query returns at most {row_limit} rows, ranked by "
980
+ f"the report's sort metric, and this account reached that ceiling. "
981
+ f"These totals cover those rows only, not the whole account. Use "
982
+ f"get_campaign_performance for account-level figures."
983
+ )
984
+
943
985
  return totals
944
986
 
945
987
 
@@ -82,6 +82,26 @@ def _resolve_path(path_str: str) -> Path:
82
82
  return Path(os.path.expandvars(os.path.expanduser(path_str)))
83
83
 
84
84
 
85
+ def _text(raw: dict, key: str, default: str = "") -> str:
86
+ """Read a string setting, treating blank as absent.
87
+
88
+ ``raw.get(key, default)`` only falls back when the key is *missing*, so an
89
+ explicit ``token_path: ""`` — easy to produce from a template with blank
90
+ placeholders — passed straight through. ``Path("")`` is ``Path(".")``, the
91
+ working directory always exists, and adloop then tried to read the cwd as
92
+ a token file and died with ``[Errno 1] Operation not permitted: '.'``,
93
+ which points nowhere near the config that caused it.
94
+
95
+ Also coerces non-strings, so ``customer_id: 1234567890`` in YAML arrives
96
+ as text rather than an int.
97
+ """
98
+ value = raw.get(key, default)
99
+
100
+ if value is None:
101
+ return default
102
+
103
+ return str(value).strip() or default
104
+
85
105
  def load_config(config_path: str | None = None) -> AdLoopConfig:
86
106
  """Load configuration from YAML file.
87
107
 
@@ -112,34 +132,34 @@ def load_config(config_path: str | None = None) -> AdLoopConfig:
112
132
 
113
133
  return AdLoopConfig(
114
134
  google=GoogleConfig(
115
- project_id=google_raw.get("project_id", ""),
116
- credentials_path=google_raw.get("credentials_path", ""),
117
- token_path=google_raw.get("token_path", "~/.adloop/token.json"),
135
+ project_id=_text(google_raw, "project_id"),
136
+ credentials_path=_text(google_raw, "credentials_path"),
137
+ token_path=_text(google_raw, "token_path", "~/.adloop/token.json"),
118
138
  ),
119
139
  ga4=GA4Config(
120
- property_id=ga4_raw.get("property_id", ""),
140
+ property_id=_text(ga4_raw, "property_id"),
121
141
  ),
122
142
  ads=AdsConfig(
123
- developer_token=ads_raw.get("developer_token", ""),
124
- customer_id=ads_raw.get("customer_id", ""),
125
- login_customer_id=ads_raw.get("login_customer_id", ""),
143
+ developer_token=_text(ads_raw, "developer_token"),
144
+ customer_id=_text(ads_raw, "customer_id"),
145
+ login_customer_id=_text(ads_raw, "login_customer_id"),
126
146
  ),
127
147
  gsc=GscConfig(
128
- site_url=gsc_raw.get("site_url", ""),
148
+ site_url=_text(gsc_raw, "site_url"),
129
149
  ),
130
150
  gtm=GtmConfig(
131
- account_id=str(gtm_raw.get("account_id", "")),
132
- container_id=str(gtm_raw.get("container_id", "")),
151
+ account_id=_text(gtm_raw, "account_id"),
152
+ container_id=_text(gtm_raw, "container_id"),
133
153
  ),
134
154
  pagespeed=PageSpeedConfig(
135
- api_key=str(pagespeed_raw.get("api_key", "")),
155
+ api_key=_text(pagespeed_raw, "api_key"),
136
156
  ),
137
157
  safety=SafetyConfig(
138
158
  max_daily_budget=safety_raw.get("max_daily_budget", 50.0),
139
159
  max_bid_increase_pct=safety_raw.get("max_bid_increase_pct", 100),
140
160
  require_dry_run=safety_raw.get("require_dry_run", True),
141
161
  two_phase_apply=safety_raw.get("two_phase_apply", False),
142
- log_file=safety_raw.get("log_file", "~/.adloop/audit.log"),
162
+ log_file=_text(safety_raw, "log_file", "~/.adloop/audit.log"),
143
163
  blocked_operations=safety_raw.get("blocked_operations", []),
144
164
  ),
145
165
  source_path=resolved,
@@ -655,16 +655,29 @@ For cities/regions, use `run_gaql` with `geo_target_constant` resource or look u
655
655
 
656
656
  ### Common Language IDs (languageConstants)
657
657
 
658
- | ID | Language |
659
- |----|----------|
660
- | 1000 | English |
661
- | 1001 | German |
662
- | 1002 | French |
663
- | 1003 | Italian |
664
- | 1004 | Spanish |
665
- | 1005 | Dutch |
666
- | 1009 | Portuguese |
667
- | 1014 | Polish |
658
+ Verified against the `language_constant` resource. Google accepts a wrong
659
+ language ID silently, so confirm anything not listed here with:
660
+ `SELECT language_constant.id, language_constant.code, language_constant.name FROM language_constant`
661
+
662
+ | ID | Code | Language |
663
+ |----|------|----------|
664
+ | 1000 | en | English |
665
+ | 1001 | de | German |
666
+ | 1002 | fr | French |
667
+ | 1003 | es | Spanish |
668
+ | 1004 | it | Italian |
669
+ | 1005 | ja | Japanese |
670
+ | 1009 | da | Danish |
671
+ | 1010 | nl | Dutch |
672
+ | 1011 | fi | Finnish |
673
+ | 1014 | pt | Portuguese |
674
+ | 1015 | sv | Swedish |
675
+ | 1017 | zh_CN | Chinese (simplified) |
676
+ | 1019 | ar | Arabic |
677
+ | 1021 | cs | Czech |
678
+ | 1030 | pl | Polish |
679
+ | 1031 | ru | Russian |
680
+ | 1037 | tr | Turkish |
668
681
 
669
682
  ## Marketing Best Practices
670
683
 
@@ -285,7 +285,7 @@ def _safe(fn: Callable) -> Callable:
285
285
  # ---------------------------------------------------------------------------
286
286
 
287
287
 
288
- @mcp.tool(annotations=_READONLY, tags={"core"})
288
+ @mcp.tool(title="Connection health check", annotations=_READONLY, tags={"core"})
289
289
  @_safe
290
290
  def health_check() -> dict:
291
291
  """Test AdLoop connectivity — checks OAuth token, GA4 API, and Google Ads API.
@@ -372,7 +372,7 @@ def health_check() -> dict:
372
372
  # ---------------------------------------------------------------------------
373
373
 
374
374
 
375
- @mcp.tool(annotations=_READONLY, tags={"ga4"})
375
+ @mcp.tool(title="List Analytics properties", annotations=_READONLY, tags={"ga4"})
376
376
  @_safe
377
377
  def get_account_summaries() -> dict:
378
378
  """List all GA4 accounts and properties accessible by the authenticated user.
@@ -385,7 +385,7 @@ def get_account_summaries() -> dict:
385
385
  return _impl(current_config())
386
386
 
387
387
 
388
- @mcp.tool(annotations=_READONLY, tags={"ga4"})
388
+ @mcp.tool(title="Analytics report", annotations=_READONLY, tags={"ga4"})
389
389
  @_safe
390
390
  def run_ga4_report(
391
391
  dimensions: _StrListOpt = None,
@@ -416,7 +416,7 @@ def run_ga4_report(
416
416
  )
417
417
 
418
418
 
419
- @mcp.tool(annotations=_READONLY, tags={"ga4"})
419
+ @mcp.tool(title="Analytics realtime report", annotations=_READONLY, tags={"ga4"})
420
420
  @_safe
421
421
  def run_realtime_report(
422
422
  dimensions: _StrListOpt = None,
@@ -439,7 +439,7 @@ def run_realtime_report(
439
439
  )
440
440
 
441
441
 
442
- @mcp.tool(annotations=_READONLY, tags={"ga4"})
442
+ @mcp.tool(title="List Analytics events", annotations=_READONLY, tags={"ga4"})
443
443
  @_safe
444
444
  def get_tracking_events(
445
445
  date_range_start: str = "28daysAgo",
@@ -466,7 +466,7 @@ def get_tracking_events(
466
466
  # ---------------------------------------------------------------------------
467
467
 
468
468
 
469
- @mcp.tool(annotations=_READONLY, tags={"gsc"})
469
+ @mcp.tool(title="List Search Console properties", annotations=_READONLY, tags={"gsc"})
470
470
  @_safe
471
471
  def list_gsc_sites() -> dict:
472
472
  """List all Google Search Console properties the authenticated user can access.
@@ -480,7 +480,7 @@ def list_gsc_sites() -> dict:
480
480
  return _impl(current_config())
481
481
 
482
482
 
483
- @mcp.tool(annotations=_READONLY, tags={"gsc"})
483
+ @mcp.tool(title="Search Console report", annotations=_READONLY, tags={"gsc"})
484
484
  @_safe
485
485
  def run_gsc_report(
486
486
  site_url: str = "",
@@ -530,7 +530,7 @@ def run_gsc_report(
530
530
  # ---------------------------------------------------------------------------
531
531
 
532
532
 
533
- @mcp.tool(annotations=_READONLY, tags={"web"})
533
+ @mcp.tool(title="PageSpeed analysis", annotations=_READONLY, tags={"web"})
534
534
  @_safe
535
535
  def analyze_page_speed(url: str, strategy: str = "mobile") -> dict:
536
536
  """Run PageSpeed Insights for a landing page — Lighthouse + real-user data.
@@ -549,7 +549,11 @@ def analyze_page_speed(url: str, strategy: str = "mobile") -> dict:
549
549
  return _impl(current_config(), url=url, strategy=strategy)
550
550
 
551
551
 
552
- @mcp.tool(annotations=_READONLY, tags={"merchant"})
552
+ @mcp.tool(
553
+ title="List Merchant Center accounts",
554
+ annotations=_READONLY,
555
+ tags={"merchant"},
556
+ )
553
557
  @_safe
554
558
  def list_merchant_accounts() -> dict:
555
559
  """List Google Merchant Center accounts the connected user can access.
@@ -562,7 +566,7 @@ def list_merchant_accounts() -> dict:
562
566
  return _impl(current_config())
563
567
 
564
568
 
565
- @mcp.tool(annotations=_READONLY, tags={"merchant"})
569
+ @mcp.tool(title="Merchant Center feed health", annotations=_READONLY, tags={"merchant"})
566
570
  @_safe
567
571
  def get_merchant_feed_health(account_id: str) -> dict:
568
572
  """Merchant Center feed health — disapproved products + account issues.
@@ -581,7 +585,7 @@ def get_merchant_feed_health(account_id: str) -> dict:
581
585
  return _impl(current_config(), account_id=account_id)
582
586
 
583
587
 
584
- @mcp.tool(annotations=_READONLY, tags={"ads"})
588
+ @mcp.tool(title="List Google Ads accounts", annotations=_READONLY, tags={"ads"})
585
589
  @_safe
586
590
  def list_accounts(limit: int = 200) -> dict:
587
591
  """List accessible Google Ads accounts.
@@ -600,7 +604,7 @@ def list_accounts(limit: int = 200) -> dict:
600
604
  return _impl(current_config(), limit=limit)
601
605
 
602
606
 
603
- @mcp.tool(annotations=_READONLY, tags={"ads"})
607
+ @mcp.tool(title="Campaign performance", annotations=_READONLY, tags={"ads"})
604
608
  @_safe
605
609
  def get_campaign_performance(
606
610
  customer_id: str = "",
@@ -629,7 +633,7 @@ def get_campaign_performance(
629
633
  )
630
634
 
631
635
 
632
- @mcp.tool(annotations=_READONLY, tags={"ads"})
636
+ @mcp.tool(title="Ad performance", annotations=_READONLY, tags={"ads"})
633
637
  @_safe
634
638
  def get_ad_performance(
635
639
  customer_id: str = "",
@@ -657,7 +661,7 @@ def get_ad_performance(
657
661
  )
658
662
 
659
663
 
660
- @mcp.tool(annotations=_READONLY, tags={"ads"})
664
+ @mcp.tool(title="Keyword performance", annotations=_READONLY, tags={"ads"})
661
665
  @_safe
662
666
  def get_keyword_performance(
663
667
  customer_id: str = "",
@@ -685,7 +689,7 @@ def get_keyword_performance(
685
689
  )
686
690
 
687
691
 
688
- @mcp.tool(annotations=_READONLY, tags={"ads"})
692
+ @mcp.tool(title="Search terms report", annotations=_READONLY, tags={"ads"})
689
693
  @_safe
690
694
  def get_search_terms(
691
695
  customer_id: str = "",
@@ -713,7 +717,7 @@ def get_search_terms(
713
717
  )
714
718
 
715
719
 
716
- @mcp.tool(annotations=_READONLY, tags={"ads"})
720
+ @mcp.tool(title="List negative keywords", annotations=_READONLY, tags={"ads"})
717
721
  @_safe
718
722
  def get_negative_keywords(
719
723
  customer_id: str = "",
@@ -733,7 +737,11 @@ def get_negative_keywords(
733
737
  )
734
738
 
735
739
 
736
- @mcp.tool(annotations=_READONLY, tags={"ads"})
740
+ @mcp.tool(
741
+ title="List shared negative keyword lists",
742
+ annotations=_READONLY,
743
+ tags={"ads"},
744
+ )
737
745
  @_safe
738
746
  def get_negative_keyword_lists(
739
747
  customer_id: str = "",
@@ -749,7 +757,11 @@ def get_negative_keyword_lists(
749
757
  return _impl(current_config(), customer_id=customer_id or current_config().ads.customer_id)
750
758
 
751
759
 
752
- @mcp.tool(annotations=_READONLY, tags={"ads"})
760
+ @mcp.tool(
761
+ title="Keywords in a negative keyword list",
762
+ annotations=_READONLY,
763
+ tags={"ads"},
764
+ )
753
765
  @_safe
754
766
  def get_negative_keyword_list_keywords(
755
767
  shared_set_id: str,
@@ -768,7 +780,11 @@ def get_negative_keyword_list_keywords(
768
780
  )
769
781
 
770
782
 
771
- @mcp.tool(annotations=_READONLY, tags={"ads"})
783
+ @mcp.tool(
784
+ title="Campaigns using a negative keyword list",
785
+ annotations=_READONLY,
786
+ tags={"ads"},
787
+ )
772
788
  @_safe
773
789
  def get_negative_keyword_list_campaigns(
774
790
  shared_set_id: str = "",
@@ -793,7 +809,7 @@ def get_negative_keyword_list_campaigns(
793
809
  # ---------------------------------------------------------------------------
794
810
 
795
811
 
796
- @mcp.tool(annotations=_READONLY, tags={"ads"})
812
+ @mcp.tool(title="Google Ads recommendations", annotations=_READONLY, tags={"ads"})
797
813
  @_safe
798
814
  def get_recommendations(
799
815
  customer_id: str = "",
@@ -822,7 +838,7 @@ def get_recommendations(
822
838
  )
823
839
 
824
840
 
825
- @mcp.tool(annotations=_READONLY, tags={"ads"})
841
+ @mcp.tool(title="Performance Max performance", annotations=_READONLY, tags={"ads"})
826
842
  @_safe
827
843
  def get_pmax_performance(
828
844
  customer_id: str = "",
@@ -853,7 +869,11 @@ def get_pmax_performance(
853
869
  )
854
870
 
855
871
 
856
- @mcp.tool(annotations=_READONLY, tags={"ads"})
872
+ @mcp.tool(
873
+ title="Performance Max asset performance",
874
+ annotations=_READONLY,
875
+ tags={"ads"},
876
+ )
857
877
  @_safe
858
878
  def get_asset_performance(
859
879
  customer_id: str = "",
@@ -882,7 +902,11 @@ def get_asset_performance(
882
902
  )
883
903
 
884
904
 
885
- @mcp.tool(annotations=_READONLY, tags={"ads"})
905
+ @mcp.tool(
906
+ title="Performance Max asset combinations",
907
+ annotations=_READONLY,
908
+ tags={"ads"},
909
+ )
886
910
  @_safe
887
911
  def get_detailed_asset_performance(
888
912
  customer_id: str = "",
@@ -905,7 +929,7 @@ def get_detailed_asset_performance(
905
929
  )
906
930
 
907
931
 
908
- @mcp.tool(annotations=_READONLY, tags={"ads"})
932
+ @mcp.tool(title="Audience performance", annotations=_READONLY, tags={"ads"})
909
933
  @_safe
910
934
  def get_audience_performance(
911
935
  customer_id: str = "",
@@ -936,7 +960,7 @@ def get_audience_performance(
936
960
  )
937
961
 
938
962
 
939
- @mcp.tool(annotations=_READONLY, tags={"ads"})
963
+ @mcp.tool(title="Demographic targeting", annotations=_READONLY, tags={"ads"})
940
964
  @_safe
941
965
  def get_demographic_targeting(
942
966
  ad_group_id: str = "",
@@ -969,7 +993,11 @@ def get_demographic_targeting(
969
993
  # ---------------------------------------------------------------------------
970
994
 
971
995
 
972
- @mcp.tool(annotations=_READONLY, tags={"tracking"})
996
+ @mcp.tool(
997
+ title="Campaign conversions against Analytics",
998
+ annotations=_READONLY,
999
+ tags={"tracking"},
1000
+ )
973
1001
  @_safe
974
1002
  def analyze_campaign_conversions(
975
1003
  date_range_start: str = "",
@@ -999,7 +1027,7 @@ def analyze_campaign_conversions(
999
1027
  )
1000
1028
 
1001
1029
 
1002
- @mcp.tool(annotations=_READONLY, tags={"tracking"})
1030
+ @mcp.tool(title="Landing page analysis", annotations=_READONLY, tags={"tracking"})
1003
1031
  @_safe
1004
1032
  def landing_page_analysis(
1005
1033
  date_range_start: str = "",
@@ -1025,7 +1053,7 @@ def landing_page_analysis(
1025
1053
  )
1026
1054
 
1027
1055
 
1028
- @mcp.tool(annotations=_READONLY, tags={"tracking"})
1056
+ @mcp.tool(title="Attribution check", annotations=_READONLY, tags={"tracking"})
1029
1057
  @_safe
1030
1058
  def attribution_check(
1031
1059
  date_range_start: str = "",
@@ -1056,7 +1084,7 @@ def attribution_check(
1056
1084
  )
1057
1085
 
1058
1086
 
1059
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1087
+ @mcp.tool(title="Tracking coverage audit", annotations=_READONLY, tags={"gtm"})
1060
1088
  @_safe
1061
1089
  def audit_event_coverage(
1062
1090
  expected_events: list[str],
@@ -1109,7 +1137,7 @@ def audit_event_coverage(
1109
1137
  )
1110
1138
 
1111
1139
 
1112
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1140
+ @mcp.tool(title="List Tag Manager accounts", annotations=_READONLY, tags={"gtm"})
1113
1141
  @_safe
1114
1142
  def list_gtm_accounts() -> dict:
1115
1143
  """List all GTM accounts the AdLoop service account / OAuth user can read.
@@ -1124,7 +1152,7 @@ def list_gtm_accounts() -> dict:
1124
1152
  return _impl(current_config())
1125
1153
 
1126
1154
 
1127
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1155
+ @mcp.tool(title="List Tag Manager containers", annotations=_READONLY, tags={"gtm"})
1128
1156
  @_safe
1129
1157
  def list_gtm_containers(gtm_account_id: str = "") -> dict:
1130
1158
  """List all containers under a GTM account.
@@ -1144,7 +1172,7 @@ def list_gtm_containers(gtm_account_id: str = "") -> dict:
1144
1172
  return _impl(current_config(), account_id=gtm_account_id)
1145
1173
 
1146
1174
 
1147
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1175
+ @mcp.tool(title="List Tag Manager tags", annotations=_READONLY, tags={"gtm"})
1148
1176
  @_safe
1149
1177
  def list_gtm_tags(gtm_account_id: str = "", gtm_container_id: str = "") -> dict:
1150
1178
  """List every tag in the LIVE GTM container.
@@ -1164,7 +1192,7 @@ def list_gtm_tags(gtm_account_id: str = "", gtm_container_id: str = "") -> dict:
1164
1192
  )
1165
1193
 
1166
1194
 
1167
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1195
+ @mcp.tool(title="Tag Manager tag details", annotations=_READONLY, tags={"gtm"})
1168
1196
  @_safe
1169
1197
  def get_gtm_tag(
1170
1198
  tag_id: str, gtm_account_id: str = "", gtm_container_id: str = ""
@@ -1189,7 +1217,7 @@ def get_gtm_tag(
1189
1217
  )
1190
1218
 
1191
1219
 
1192
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1220
+ @mcp.tool(title="List Tag Manager triggers", annotations=_READONLY, tags={"gtm"})
1193
1221
  @_safe
1194
1222
  def list_gtm_triggers(gtm_account_id: str = "", gtm_container_id: str = "") -> dict:
1195
1223
  """List every trigger in the LIVE GTM container.
@@ -1209,7 +1237,7 @@ def list_gtm_triggers(gtm_account_id: str = "", gtm_container_id: str = "") -> d
1209
1237
  )
1210
1238
 
1211
1239
 
1212
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1240
+ @mcp.tool(title="Tag Manager trigger details", annotations=_READONLY, tags={"gtm"})
1213
1241
  @_safe
1214
1242
  def get_gtm_trigger(
1215
1243
  trigger_id: str, gtm_account_id: str = "", gtm_container_id: str = ""
@@ -1234,7 +1262,7 @@ def get_gtm_trigger(
1234
1262
  )
1235
1263
 
1236
1264
 
1237
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1265
+ @mcp.tool(title="List Tag Manager variables", annotations=_READONLY, tags={"gtm"})
1238
1266
  @_safe
1239
1267
  def list_gtm_variables(gtm_account_id: str = "", gtm_container_id: str = "") -> dict:
1240
1268
  """List GTM variables — both custom and enabled built-in.
@@ -1256,7 +1284,7 @@ def list_gtm_variables(gtm_account_id: str = "", gtm_container_id: str = "") ->
1256
1284
  )
1257
1285
 
1258
1286
 
1259
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1287
+ @mcp.tool(title="List Tag Manager workspaces", annotations=_READONLY, tags={"gtm"})
1260
1288
  @_safe
1261
1289
  def list_gtm_workspaces(gtm_account_id: str = "", gtm_container_id: str = "") -> dict:
1262
1290
  """List workspaces (drafts) under a GTM container.
@@ -1276,7 +1304,7 @@ def list_gtm_workspaces(gtm_account_id: str = "", gtm_container_id: str = "") ->
1276
1304
  )
1277
1305
 
1278
1306
 
1279
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1307
+ @mcp.tool(title="Tag Manager workspace changes", annotations=_READONLY, tags={"gtm"})
1280
1308
  @_safe
1281
1309
  def get_gtm_workspace_diff(
1282
1310
  workspace_id: str, gtm_account_id: str = "", gtm_container_id: str = ""
@@ -1303,7 +1331,7 @@ def get_gtm_workspace_diff(
1303
1331
  )
1304
1332
 
1305
1333
 
1306
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1334
+ @mcp.tool(title="Tag Manager version history", annotations=_READONLY, tags={"gtm"})
1307
1335
  @_safe
1308
1336
  def list_gtm_versions(
1309
1337
  gtm_account_id: str = "", gtm_container_id: str = "", page_size: int = 50
@@ -1329,7 +1357,7 @@ def list_gtm_versions(
1329
1357
  )
1330
1358
 
1331
1359
 
1332
- @mcp.tool(annotations=_READONLY, tags={"gtm"})
1360
+ @mcp.tool(title="Tag Manager version details", annotations=_READONLY, tags={"gtm"})
1333
1361
  @_safe
1334
1362
  def get_gtm_version(
1335
1363
  container_version_id: str, gtm_account_id: str = "", gtm_container_id: str = ""
@@ -1354,7 +1382,7 @@ def get_gtm_version(
1354
1382
  )
1355
1383
 
1356
1384
 
1357
- @mcp.tool(annotations=_READONLY, tags={"ads"})
1385
+ @mcp.tool(title="Custom Google Ads query", annotations=_READONLY, tags={"ads"})
1358
1386
  @_safe
1359
1387
  def run_gaql(
1360
1388
  query: str,
@@ -1383,7 +1411,7 @@ def run_gaql(
1383
1411
  # ---------------------------------------------------------------------------
1384
1412
 
1385
1413
 
1386
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1414
+ @mcp.tool(title="Draft a campaign", annotations=_WRITE, tags={"ads"})
1387
1415
  @_safe
1388
1416
  def draft_campaign(
1389
1417
  campaign_name: str,
@@ -1450,7 +1478,7 @@ def draft_campaign(
1450
1478
  )
1451
1479
 
1452
1480
 
1453
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1481
+ @mcp.tool(title="Draft an ad group", annotations=_WRITE, tags={"ads"})
1454
1482
  @_safe
1455
1483
  def draft_ad_group(
1456
1484
  campaign_id: str,
@@ -1483,7 +1511,7 @@ def draft_ad_group(
1483
1511
  )
1484
1512
 
1485
1513
 
1486
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1514
+ @mcp.tool(title="Draft campaign changes", annotations=_WRITE, tags={"ads"})
1487
1515
  @_safe
1488
1516
  def update_campaign(
1489
1517
  campaign_id: str,
@@ -1540,7 +1568,7 @@ def update_campaign(
1540
1568
  )
1541
1569
 
1542
1570
 
1543
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1571
+ @mcp.tool(title="Draft a responsive search ad", annotations=_WRITE, tags={"ads"})
1544
1572
  @_safe
1545
1573
  def draft_responsive_search_ad(
1546
1574
  ad_group_id: str,
@@ -1583,7 +1611,7 @@ def draft_responsive_search_ad(
1583
1611
  )
1584
1612
 
1585
1613
 
1586
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1614
+ @mcp.tool(title="Draft keywords", annotations=_WRITE, tags={"ads"})
1587
1615
  @_safe
1588
1616
  def draft_keywords(
1589
1617
  ad_group_id: str,
@@ -1605,7 +1633,7 @@ def draft_keywords(
1605
1633
  )
1606
1634
 
1607
1635
 
1608
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1636
+ @mcp.tool(title="Draft negative keywords", annotations=_WRITE, tags={"ads"})
1609
1637
  @_safe
1610
1638
  def add_negative_keywords(
1611
1639
  campaign_id: str,
@@ -1630,7 +1658,7 @@ def add_negative_keywords(
1630
1658
  )
1631
1659
 
1632
1660
 
1633
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1661
+ @mcp.tool(title="Draft negative locations", annotations=_WRITE, tags={"ads"})
1634
1662
  @_safe
1635
1663
  def add_negative_locations(
1636
1664
  campaign_id: str,
@@ -1654,7 +1682,11 @@ def add_negative_locations(
1654
1682
  )
1655
1683
 
1656
1684
 
1657
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1685
+ @mcp.tool(
1686
+ title="Draft a shared negative keyword list",
1687
+ annotations=_WRITE,
1688
+ tags={"ads"},
1689
+ )
1658
1690
  @_safe
1659
1691
  def propose_negative_keyword_list(
1660
1692
  campaign_id: str,
@@ -1682,7 +1714,11 @@ def propose_negative_keyword_list(
1682
1714
  )
1683
1715
 
1684
1716
 
1685
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1717
+ @mcp.tool(
1718
+ title="Draft additions to a negative keyword list",
1719
+ annotations=_WRITE,
1720
+ tags={"ads"},
1721
+ )
1686
1722
  @_safe
1687
1723
  def add_to_negative_keyword_list(
1688
1724
  shared_set_id: str,
@@ -1715,7 +1751,7 @@ def add_to_negative_keyword_list(
1715
1751
  )
1716
1752
 
1717
1753
 
1718
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1754
+ @mcp.tool(title="Draft attaching a shared set", annotations=_WRITE, tags={"ads"})
1719
1755
  @_safe
1720
1756
  def attach_shared_set_to_campaigns(
1721
1757
  shared_set_id: str,
@@ -1746,7 +1782,7 @@ def attach_shared_set_to_campaigns(
1746
1782
  )
1747
1783
 
1748
1784
 
1749
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1785
+ @mcp.tool(title="Draft detaching a shared set", annotations=_WRITE, tags={"ads"})
1750
1786
  @_safe
1751
1787
  def detach_shared_set_from_campaigns(
1752
1788
  shared_set_id: str,
@@ -1777,7 +1813,7 @@ def detach_shared_set_from_campaigns(
1777
1813
  )
1778
1814
 
1779
1815
 
1780
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1816
+ @mcp.tool(title="Draft demographic targeting", annotations=_WRITE, tags={"ads"})
1781
1817
  @_safe
1782
1818
  def draft_demographic_targeting(
1783
1819
  customer_id: str = "",
@@ -1829,7 +1865,7 @@ def draft_demographic_targeting(
1829
1865
  )
1830
1866
 
1831
1867
 
1832
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1868
+ @mcp.tool(title="Draft ad group changes", annotations=_WRITE, tags={"ads"})
1833
1869
  @_safe
1834
1870
  def update_ad_group(
1835
1871
  ad_group_id: str,
@@ -1849,7 +1885,7 @@ def update_ad_group(
1849
1885
  )
1850
1886
 
1851
1887
 
1852
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1888
+ @mcp.tool(title="Draft callouts", annotations=_WRITE, tags={"ads"})
1853
1889
  @_safe
1854
1890
  def draft_callouts(
1855
1891
  campaign_id: str,
@@ -1867,7 +1903,7 @@ def draft_callouts(
1867
1903
  )
1868
1904
 
1869
1905
 
1870
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1906
+ @mcp.tool(title="Draft structured snippets", annotations=_WRITE, tags={"ads"})
1871
1907
  @_safe
1872
1908
  def draft_structured_snippets(
1873
1909
  campaign_id: str,
@@ -1885,7 +1921,7 @@ def draft_structured_snippets(
1885
1921
  )
1886
1922
 
1887
1923
 
1888
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1924
+ @mcp.tool(title="Draft image assets", annotations=_WRITE, tags={"ads"})
1889
1925
  @_safe
1890
1926
  def draft_image_assets(
1891
1927
  campaign_id: str,
@@ -1903,7 +1939,7 @@ def draft_image_assets(
1903
1939
  )
1904
1940
 
1905
1941
 
1906
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1942
+ @mcp.tool(title="Draft pausing an entity", annotations=_WRITE, tags={"ads"})
1907
1943
  @_safe
1908
1944
  def pause_entity(
1909
1945
  entity_type: str,
@@ -1931,7 +1967,7 @@ def pause_entity(
1931
1967
  )
1932
1968
 
1933
1969
 
1934
- @mcp.tool(annotations=_WRITE, tags={"ads"})
1970
+ @mcp.tool(title="Draft enabling an entity", annotations=_WRITE, tags={"ads"})
1935
1971
  @_safe
1936
1972
  def enable_entity(
1937
1973
  entity_type: str,
@@ -1959,7 +1995,7 @@ def enable_entity(
1959
1995
  )
1960
1996
 
1961
1997
 
1962
- @mcp.tool(annotations=_DESTRUCTIVE, tags={"ads"})
1998
+ @mcp.tool(title="Draft removing an entity", annotations=_DESTRUCTIVE, tags={"ads"})
1963
1999
  @_safe
1964
2000
  def remove_entity(
1965
2001
  entity_type: str,
@@ -1995,7 +2031,7 @@ def remove_entity(
1995
2031
  )
1996
2032
 
1997
2033
 
1998
- @mcp.tool(annotations=_WRITE, tags={"ads"})
2034
+ @mcp.tool(title="Draft sitelinks", annotations=_WRITE, tags={"ads"})
1999
2035
  @_safe
2000
2036
  def draft_sitelinks(
2001
2037
  campaign_id: str,
@@ -2028,7 +2064,7 @@ def draft_sitelinks(
2028
2064
  )
2029
2065
 
2030
2066
 
2031
- @mcp.tool(annotations=_DESTRUCTIVE, tags={"core"})
2067
+ @mcp.tool(title="Apply a previewed change", annotations=_DESTRUCTIVE, tags={"core"})
2032
2068
  @_safe
2033
2069
  def confirm_and_apply(
2034
2070
  plan_id: str,
@@ -2065,7 +2101,7 @@ def confirm_and_apply(
2065
2101
  # ---------------------------------------------------------------------------
2066
2102
 
2067
2103
 
2068
- @mcp.tool(annotations=_WRITE, tags={"ga4"})
2104
+ @mcp.tool(title="Draft an Analytics key event", annotations=_WRITE, tags={"ga4"})
2069
2105
  @_safe
2070
2106
  def draft_key_event(
2071
2107
  event_name: str,
@@ -2090,7 +2126,7 @@ def draft_key_event(
2090
2126
  )
2091
2127
 
2092
2128
 
2093
- @mcp.tool(annotations=_READONLY, tags={"tracking"})
2129
+ @mcp.tool(title="Validate tracking", annotations=_READONLY, tags={"tracking"})
2094
2130
  @_safe
2095
2131
  def validate_tracking(
2096
2132
  expected_events: _StrList,
@@ -2118,7 +2154,7 @@ def validate_tracking(
2118
2154
  )
2119
2155
 
2120
2156
 
2121
- @mcp.tool(annotations=_READONLY, tags={"tracking"})
2157
+ @mcp.tool(title="Generate tracking code", annotations=_READONLY, tags={"tracking"})
2122
2158
  @_safe
2123
2159
  def generate_tracking_code(
2124
2160
  event_name: str,
@@ -2153,7 +2189,7 @@ def generate_tracking_code(
2153
2189
  # ---------------------------------------------------------------------------
2154
2190
 
2155
2191
 
2156
- @mcp.tool(annotations=_READONLY, tags={"ads"})
2192
+ @mcp.tool(title="Budget forecast", annotations=_READONLY, tags={"ads"})
2157
2193
  @_safe
2158
2194
  def estimate_budget(
2159
2195
  keywords: _DictList,
@@ -2190,7 +2226,7 @@ def estimate_budget(
2190
2226
  )
2191
2227
 
2192
2228
 
2193
- @mcp.tool(annotations=_READONLY, tags={"ads"})
2229
+ @mcp.tool(title="Keyword ideas", annotations=_READONLY, tags={"ads"})
2194
2230
  @_safe
2195
2231
  def discover_keywords(
2196
2232
  seed_keywords: _StrList = [], # noqa: B006 — mutable default required for MCP JSON schema
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