adloop 0.27.1__tar.gz → 0.28.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 (71) hide show
  1. {adloop-0.27.1 → adloop-0.28.0}/PKG-INFO +11 -3
  2. {adloop-0.27.1 → adloop-0.28.0}/README.md +10 -2
  3. {adloop-0.27.1 → adloop-0.28.0}/pyproject.toml +1 -1
  4. {adloop-0.27.1 → adloop-0.28.0}/pyproject.toml.orig +1 -1
  5. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/write.py +32 -4
  6. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/cli.py +6 -3
  7. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/server.py +44 -13
  8. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/__init__.py +0 -0
  9. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/__main__.py +0 -0
  10. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/_mcp_patches.py +0 -0
  11. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/__init__.py +0 -0
  12. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/ai_max.py +0 -0
  13. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/brands.py +0 -0
  14. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/client.py +0 -0
  15. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/conversion_actions.py +0 -0
  16. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/currency.py +0 -0
  17. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  18. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/enums.py +0 -0
  19. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/forecast.py +0 -0
  20. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/gaql.py +0 -0
  21. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/image_fetch.py +0 -0
  22. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/pmax.py +0 -0
  23. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/read.py +0 -0
  24. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/shopping.py +0 -0
  25. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/validate_only.py +0 -0
  26. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/auth.py +0 -0
  27. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/config.py +0 -0
  28. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/crossref.py +0 -0
  29. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/diagnostics.py +0 -0
  30. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ga4/__init__.py +0 -0
  31. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ga4/client.py +0 -0
  32. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ga4/reports.py +0 -0
  33. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ga4/tracking.py +0 -0
  34. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ga4/write.py +0 -0
  35. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gsc/__init__.py +0 -0
  36. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gsc/client.py +0 -0
  37. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gsc/reports.py +0 -0
  38. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gtm/__init__.py +0 -0
  39. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gtm/client.py +0 -0
  40. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gtm/entities.py +0 -0
  41. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gtm/read.py +0 -0
  42. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gtm/write.py +0 -0
  43. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/merchant/__init__.py +0 -0
  44. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/merchant/client.py +0 -0
  45. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/merchant/read.py +0 -0
  46. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/pagespeed.py +0 -0
  47. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/__init__.py +0 -0
  48. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/auth.py +0 -0
  49. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/client.py +0 -0
  50. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/read.py +0 -0
  51. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/schedule.py +0 -0
  52. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/write.py +0 -0
  53. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/__init__.py +0 -0
  54. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/adloop.md +0 -0
  55. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  56. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  57. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/create-ad.md +0 -0
  58. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  59. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  60. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  61. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules_install.py +0 -0
  62. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/runtime.py +0 -0
  63. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/__init__.py +0 -0
  64. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/approval.py +0 -0
  65. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/approval_card.html +0 -0
  66. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/audit.py +0 -0
  67. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/guards.py +0 -0
  68. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/preview.py +0 -0
  69. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/tracking.py +0 -0
  70. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/upload_card.html +0 -0
  71. {adloop-0.27.1 → adloop-0.28.0}/src/adloop/uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.27.1
3
+ Version: 0.28.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
@@ -557,7 +557,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
557
557
 
558
558
  ### Toolsets — trim the context footprint
559
559
 
560
- Most MCP clients (claude.ai, ChatGPT, Cursor, …) load **every tool schema into the model's context at the start of every conversation**. AdLoop's full catalog costs roughly 18k tokens per session that way — paid before you type a word. If you only use part of AdLoop, expose a subset with the `ADLOOP_TOOLSETS` environment variable in your MCP client's `env` block (the `adloop init` wizard offers this and writes it into the snippets for you):
560
+ Most MCP clients (claude.ai, ChatGPT, Cursor, …) load **every tool schema into the model's context at the start of every conversation**. With AdLoop's full catalog that's a noticeable share of the context window, paid before you type a word. If you only use part of AdLoop, expose a subset with the `ADLOOP_TOOLSETS` environment variable in your MCP client's `env` block (the `adloop init` wizard offers this and writes it into the snippets for you):
561
561
 
562
562
  ```json
563
563
  "env": { "ADLOOP_TOOLSETS": "ads,ga4" }
@@ -574,7 +574,15 @@ Most MCP clients (claude.ai, ChatGPT, Cursor, …) load **every tool schema into
574
574
  | `merchant` | Merchant Center feed health |
575
575
  | `reddit` | Reddit Ads reads, writes, and planning |
576
576
 
577
- `health_check` and `confirm_and_apply` are always included, whatever you select. Unset = the full catalog; unknown names fail at startup with the valid list. The effect is real: `ads,ga4` drops the session cost to ~13k tokens, and a `ga4`-only client pays ~2k — nearly 90% less. Toolsets are per *client*, not per install: one AdLoop config can serve a trimmed Cursor and a full-catalog Claude Code side by side.
577
+ Google Ads can be narrowed further. Use one of these instead of `ads`:
578
+
579
+ | Subset | Covers |
580
+ |---|---|
581
+ | `ads:read` | Ads reports, GAQL, and the Ads/Analytics cross-checks (`attribution_check`, `analyze_campaign_conversions`, `landing_page_analysis`) |
582
+ | `ads:write` | Ads drafts and changes; turns on `ads:read` too, because a draft needs the reads first (IDs, bidding strategy) |
583
+ | `ads:plan` | Keyword ideas and budget forecasts (`discover_keywords`, `estimate_budget`) |
584
+
585
+ `health_check` and `confirm_and_apply` are always included, whatever you select. Unset = the full catalog; unknown names fail at startup with the valid list. The effect is real: `ads:read,ga4` costs about 40% of `ads,ga4` for audits and diagnosis, and an `ads:plan`-only client for keyword research pays about 5% of the full catalog. Toolsets are per *client*, not per install: one AdLoop config can serve a trimmed Cursor and a full-catalog Claude Code side by side.
578
586
 
579
587
  On [AdLoop Cloud](https://getadloop.com), the same feature is per API key: pick toolsets when creating a key in the dashboard, and that key's tools/list is trimmed server-side for whichever AI client uses it.
580
588
 
@@ -529,7 +529,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
529
529
 
530
530
  ### Toolsets — trim the context footprint
531
531
 
532
- Most MCP clients (claude.ai, ChatGPT, Cursor, …) load **every tool schema into the model's context at the start of every conversation**. AdLoop's full catalog costs roughly 18k tokens per session that way — paid before you type a word. If you only use part of AdLoop, expose a subset with the `ADLOOP_TOOLSETS` environment variable in your MCP client's `env` block (the `adloop init` wizard offers this and writes it into the snippets for you):
532
+ Most MCP clients (claude.ai, ChatGPT, Cursor, …) load **every tool schema into the model's context at the start of every conversation**. With AdLoop's full catalog that's a noticeable share of the context window, paid before you type a word. If you only use part of AdLoop, expose a subset with the `ADLOOP_TOOLSETS` environment variable in your MCP client's `env` block (the `adloop init` wizard offers this and writes it into the snippets for you):
533
533
 
534
534
  ```json
535
535
  "env": { "ADLOOP_TOOLSETS": "ads,ga4" }
@@ -546,7 +546,15 @@ Most MCP clients (claude.ai, ChatGPT, Cursor, …) load **every tool schema into
546
546
  | `merchant` | Merchant Center feed health |
547
547
  | `reddit` | Reddit Ads reads, writes, and planning |
548
548
 
549
- `health_check` and `confirm_and_apply` are always included, whatever you select. Unset = the full catalog; unknown names fail at startup with the valid list. The effect is real: `ads,ga4` drops the session cost to ~13k tokens, and a `ga4`-only client pays ~2k — nearly 90% less. Toolsets are per *client*, not per install: one AdLoop config can serve a trimmed Cursor and a full-catalog Claude Code side by side.
549
+ Google Ads can be narrowed further. Use one of these instead of `ads`:
550
+
551
+ | Subset | Covers |
552
+ |---|---|
553
+ | `ads:read` | Ads reports, GAQL, and the Ads/Analytics cross-checks (`attribution_check`, `analyze_campaign_conversions`, `landing_page_analysis`) |
554
+ | `ads:write` | Ads drafts and changes; turns on `ads:read` too, because a draft needs the reads first (IDs, bidding strategy) |
555
+ | `ads:plan` | Keyword ideas and budget forecasts (`discover_keywords`, `estimate_budget`) |
556
+
557
+ `health_check` and `confirm_and_apply` are always included, whatever you select. Unset = the full catalog; unknown names fail at startup with the valid list. The effect is real: `ads:read,ga4` costs about 40% of `ads,ga4` for audits and diagnosis, and an `ads:plan`-only client for keyword research pays about 5% of the full catalog. Toolsets are per *client*, not per install: one AdLoop config can serve a trimmed Cursor and a full-catalog Claude Code side by side.
550
558
 
551
559
  On [AdLoop Cloud](https://getadloop.com), the same feature is per API key: pick toolsets when creating a key in the dashboard, and that key's tools/list is trimmed server-side for whichever AI client uses it.
552
560
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.27.1"
3
+ version = "0.28.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.27.1"
3
+ version = "0.28.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 = [
@@ -7,6 +7,7 @@ Every write tool returns a preview/plan. Nothing executes until
7
7
  from __future__ import annotations
8
8
 
9
9
  import hashlib
10
+ import re
10
11
  import struct
11
12
  from pathlib import Path, PurePath, PurePosixPath
12
13
  from typing import TYPE_CHECKING
@@ -40,6 +41,31 @@ _VALID_IMAGE_MIME_TYPES = {
40
41
  _VALID_HEADLINE_PINS = {"HEADLINE_1", "HEADLINE_2", "HEADLINE_3"}
41
42
  _VALID_DESCRIPTION_PINS = {"DESCRIPTION_1", "DESCRIPTION_2"}
42
43
 
44
+ # Insertion tags Google expands at serve time. Only the default text after
45
+ # the colon counts toward the 30/90 character caps, e.g.
46
+ # "{KeyWord:Clogged Drain Service}" is 21 characters, not 31. Written with
47
+ # spaces ("{ KeyWord : x }") it is not a tag and counts as plain text.
48
+ _INSERTION_TAG_RE = re.compile(r"\{(keyword|location\([a-z]+\)):([^{}]*)\}", re.IGNORECASE)
49
+ _BARE_INSERTION_TAG_RE = re.compile(r"\{(keyword|location\([a-z]+\))\}", re.IGNORECASE)
50
+
51
+
52
+ def _counted_length(text: str) -> int:
53
+ """Length Google checks against the cap: insertion tags count as their default text."""
54
+ return len(_INSERTION_TAG_RE.sub(lambda m: m.group(2), text))
55
+
56
+
57
+ def _insertion_tag_errors(label: str, text: str) -> list[str]:
58
+ """RSA keyword/location insertion needs default text, e.g. {KeyWord:Drain Cleaning}."""
59
+ missing = _BARE_INSERTION_TAG_RE.search(text) or any(
60
+ not m.group(2).strip() for m in _INSERTION_TAG_RE.finditer(text)
61
+ )
62
+ if missing:
63
+ return [
64
+ f"{label} uses an insertion tag without default text: '{text}'. "
65
+ "Default text after a colon is required, e.g. {KeyWord:Drain Cleaning}"
66
+ ]
67
+ return []
68
+
43
69
 
44
70
  def _normalize_rsa_assets(items: list) -> list[dict]:
45
71
  """Accept str or {text, pinned_field?} dict entries; return list of dicts.
@@ -3435,9 +3461,10 @@ def _validate_rsa_assets(
3435
3461
  for i, h in enumerate(headlines):
3436
3462
  text = h["text"]
3437
3463
  pin = h["pinned_field"]
3438
- if len(text) > 30:
3464
+ errors.extend(_insertion_tag_errors(f"Headline {i + 1}", text))
3465
+ if _counted_length(text) > 30:
3439
3466
  errors.append(
3440
- f"Headline {i + 1} exceeds 30 chars ({len(text)}): '{text}'"
3467
+ f"Headline {i + 1} exceeds 30 chars ({_counted_length(text)}): '{text}'"
3441
3468
  )
3442
3469
  if pin is not None:
3443
3470
  if pin not in _VALID_HEADLINE_PINS:
@@ -3455,9 +3482,10 @@ def _validate_rsa_assets(
3455
3482
  for i, d in enumerate(descriptions):
3456
3483
  text = d["text"]
3457
3484
  pin = d["pinned_field"]
3458
- if len(text) > 90:
3485
+ errors.extend(_insertion_tag_errors(f"Description {i + 1}", text))
3486
+ if _counted_length(text) > 90:
3459
3487
  errors.append(
3460
- f"Description {i + 1} exceeds 90 chars ({len(text)}): '{text}'"
3488
+ f"Description {i + 1} exceeds 90 chars ({_counted_length(text)}): '{text}'"
3461
3489
  )
3462
3490
  if pin is not None:
3463
3491
  if pin not in _VALID_DESCRIPTION_PINS:
@@ -281,13 +281,16 @@ def _prompt_toolsets() -> str:
281
281
  Client-side (env), not config.yaml: the same machine can run one
282
282
  full-catalog client and one trimmed client off one AdLoop config.
283
283
  """
284
- from adloop.server import TOOLSETS
284
+ from adloop.server import ADS_SUBSETS, TOOLSETS
285
285
 
286
286
  _print(" AdLoop can expose only some toolsets to your AI client —")
287
287
  _print(" a smaller tool list costs less context per conversation.")
288
288
  _print()
289
289
  for slug, description in TOOLSETS.items():
290
290
  _print(f" {slug:<9} {description}")
291
+ _print(" Or only part of Google Ads instead of ads:")
292
+ for slug, description in ADS_SUBSETS.items():
293
+ _print(f" {slug:<9} {description}")
291
294
  _print()
292
295
  _print(" (health_check and confirm_and_apply are always included.)")
293
296
  if _prompt_bool("Expose the full toolset?", default=True):
@@ -295,12 +298,12 @@ def _prompt_toolsets() -> str:
295
298
  while True:
296
299
  raw = _prompt("Toolsets (comma-separated, e.g. ads,ga4)")
297
300
  chosen = [part.strip().lower() for part in raw.split(",") if part.strip()]
298
- unknown = [slug for slug in chosen if slug not in TOOLSETS]
301
+ unknown = [slug for slug in chosen if slug not in TOOLSETS and slug not in ADS_SUBSETS]
299
302
  if chosen and not unknown:
300
303
  return ",".join(dict.fromkeys(chosen))
301
304
  _print(
302
305
  f" ⚠ Unknown toolset(s): {', '.join(unknown) or '(none given)'}."
303
- f" Valid: {', '.join(TOOLSETS)}"
306
+ f" Valid: {', '.join([*TOOLSETS, *ADS_SUBSETS])}"
304
307
  )
305
308
 
306
309
 
@@ -48,6 +48,18 @@ TOOLSETS: dict[str, str] = {
48
48
  "reddit": "Reddit Ads reads, writes, and planning",
49
49
  }
50
50
 
51
+ # Finer selections inside "ads" (issue #56): every ads tool also carries
52
+ # exactly one of these, derived in _tool() from its annotations, so "ads"
53
+ # keeps its meaning and a smaller session can ask for just the part it
54
+ # needs. The cross-reference tools that query Ads carry "ads:read" next to
55
+ # their "tracking" tag. Not part of the Cloud contract above.
56
+ ADS_SUBSETS: dict[str, str] = {
57
+ "ads:read": "Google Ads reports, GAQL and the Ads/Analytics cross-checks",
58
+ "ads:write": "Google Ads drafts and changes (turns on ads:read too)",
59
+ "ads:plan": "Keyword ideas and budget forecasts",
60
+ }
61
+ _ADS_PLAN_TOOLS = frozenset({"discover_keywords", "estimate_budget"})
62
+
51
63
 
52
64
  def _coerce_json_string_to_list(value):
53
65
  """Decode a JSON-array-shaped string into a native list.
@@ -387,17 +399,25 @@ def _tool(*, title: str, annotations: ToolAnnotations, tags: set[str], **kwargs)
387
399
  description = f"{description}\n\n{docs}"
388
400
  name = kwargs.get("name") or fn.__name__
389
401
  options = dict(kwargs)
402
+ tool_tags = set(tags)
403
+ if "ads" in tool_tags and not tool_tags & set(ADS_SUBSETS):
404
+ if name in _ADS_PLAN_TOOLS:
405
+ tool_tags.add("ads:plan")
406
+ elif annotations.read_only_hint:
407
+ tool_tags.add("ads:read")
408
+ else:
409
+ tool_tags.add("ads:write")
390
410
  if not annotations.read_only_hint and name not in _NO_CARD_TOOLS:
391
411
  # Write tools return a preview; apps with MCP Apps support
392
412
  # render it as the approval card.
393
413
  options["meta"] = {**(options.get("meta") or {}), "ui": {"resourceUri": CARD_URI}}
394
414
  fn = _with_approval_card(fn)
395
415
  if not is_app_only(options.get("meta")):
396
- _TOOL_TAGS[name] = frozenset(tags)
416
+ _TOOL_TAGS[name] = frozenset(tool_tags)
397
417
  return mcp.tool(
398
418
  title=title,
399
419
  annotations=annotations,
400
- tags=tags,
420
+ tags=tool_tags,
401
421
  description=description,
402
422
  **options,
403
423
  )(fn)
@@ -1739,7 +1759,7 @@ def get_change_history(
1739
1759
  @_tool(
1740
1760
  title="Campaign conversions against Analytics",
1741
1761
  annotations=_READONLY,
1742
- tags={"tracking"},
1762
+ tags={"tracking", "ads:read"},
1743
1763
  )
1744
1764
  @_safe
1745
1765
  def analyze_campaign_conversions(
@@ -1780,7 +1800,7 @@ def analyze_campaign_conversions(
1780
1800
  )
1781
1801
 
1782
1802
 
1783
- @_tool(title="Landing page analysis", annotations=_READONLY, tags={"tracking"})
1803
+ @_tool(title="Landing page analysis", annotations=_READONLY, tags={"tracking", "ads:read"})
1784
1804
  @_safe
1785
1805
  def landing_page_analysis(
1786
1806
  date_range_start: str = "",
@@ -1815,7 +1835,7 @@ def landing_page_analysis(
1815
1835
  )
1816
1836
 
1817
1837
 
1818
- @_tool(title="Attribution check", annotations=_READONLY, tags={"tracking"})
1838
+ @_tool(title="Attribution check", annotations=_READONLY, tags={"tracking", "ads:read"})
1819
1839
  @_safe
1820
1840
  def attribution_check(
1821
1841
  date_range_start: str = "",
@@ -3117,10 +3137,13 @@ def draft_responsive_search_ad(
3117
3137
  ad_group_id: Numeric ID of the ad group that gets the ad.
3118
3138
  headlines: 3-15 headlines, max 30 chars each. Each entry is a plain
3119
3139
  string (unpinned) or {"text": "...", "pinned_field": "HEADLINE_1"};
3120
- valid pins are HEADLINE_1, HEADLINE_2, HEADLINE_3.
3140
+ valid pins are HEADLINE_1, HEADLINE_2, HEADLINE_3. Keyword or
3141
+ location insertion such as {KeyWord:Drain Cleaning} counts as its
3142
+ default text, which is required.
3121
3143
  descriptions: 2-4 descriptions, max 90 chars each. Each entry is a plain
3122
3144
  string (unpinned) or {"text": "...", "pinned_field": "DESCRIPTION_1"};
3123
- valid pins are DESCRIPTION_1, DESCRIPTION_2.
3145
+ valid pins are DESCRIPTION_1, DESCRIPTION_2. Insertion tags count
3146
+ as their default text, as for headlines.
3124
3147
  final_url: Landing page URL the ad links to; must be reachable.
3125
3148
  customer_id: Google Ads customer ID (digits, e.g. "1234567890"). Empty
3126
3149
  uses the configured default account.
@@ -3183,11 +3206,13 @@ def update_responsive_search_ad(
3183
3206
  fully swaps in for the existing headlines. Google's RSA constraints
3184
3207
  apply: 3-15 headlines, max 30 chars each, at most 2 per pin slot.
3185
3208
  Each entry is a plain string (unpinned) or {"text": "...",
3186
- "pinned_field": "HEADLINE_1"} (HEADLINE_1-3).
3209
+ "pinned_field": "HEADLINE_1"} (HEADLINE_1-3). Insertion tags such as
3210
+ {KeyWord:Clogged Drain Service} count as their default text, which
3211
+ is required.
3187
3212
  descriptions: LIST-REPLACE: None or [] means no change; a non-empty list
3188
3213
  fully swaps in. 2-4 descriptions, max 90 chars each, at most 1 per
3189
- pin slot (DESCRIPTION_1, DESCRIPTION_2). Same entry format as
3190
- headlines.
3214
+ pin slot (DESCRIPTION_1, DESCRIPTION_2). Same entry format and
3215
+ insertion-tag counting as headlines.
3191
3216
  final_url: Empty means no change; non-empty replaces the final URL and
3192
3217
  is checked for reachability.
3193
3218
  path1: Empty means no change; non-empty sets the first display path
@@ -6470,18 +6495,24 @@ def _apply_toolsets_env() -> None:
6470
6495
 
6471
6496
  Unset/empty = the full catalog. Core tools (health_check,
6472
6497
  confirm_and_apply) survive every selection. A smaller tools/list costs
6473
- less context in MCP clients that load all tool schemas upfront.
6498
+ less context in MCP clients that load all tool schemas upfront. Besides
6499
+ the toolsets, the ads subsets (ads:read, ads:write, ads:plan) narrow
6500
+ Google Ads further; ads:write brings ads:read along, because a draft
6501
+ needs the reads first (IDs, bidding strategy).
6474
6502
  """
6475
6503
  raw = _os.getenv("ADLOOP_TOOLSETS", "").strip()
6476
6504
  if not raw:
6477
6505
  return
6478
6506
  requested = {part.strip().lower() for part in raw.split(",") if part.strip()}
6479
- unknown = sorted(requested - set(TOOLSETS))
6507
+ unknown = sorted(requested - set(TOOLSETS) - set(ADS_SUBSETS))
6480
6508
  if unknown:
6481
6509
  raise ValueError(
6482
6510
  f"ADLOOP_TOOLSETS names unknown toolset(s): {', '.join(unknown)}. "
6483
- f"Valid toolsets: {', '.join(TOOLSETS)}. Example: ADLOOP_TOOLSETS=ads,ga4"
6511
+ f"Valid toolsets: {', '.join([*TOOLSETS, *ADS_SUBSETS])}. "
6512
+ "Example: ADLOOP_TOOLSETS=ads,ga4 or ADLOOP_TOOLSETS=ads:read,ga4"
6484
6513
  )
6514
+ if "ads:write" in requested:
6515
+ requested.add("ads:read")
6485
6516
  global _ENABLED_TAGS
6486
6517
  _ENABLED_TAGS = requested | {"core"}
6487
6518
  mcp.enable(tags=_ENABLED_TAGS, only=True)
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
File without changes