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.
- {adloop-0.27.1 → adloop-0.28.0}/PKG-INFO +11 -3
- {adloop-0.27.1 → adloop-0.28.0}/README.md +10 -2
- {adloop-0.27.1 → adloop-0.28.0}/pyproject.toml +1 -1
- {adloop-0.27.1 → adloop-0.28.0}/pyproject.toml.orig +1 -1
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/write.py +32 -4
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/cli.py +6 -3
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/server.py +44 -13
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/__init__.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/__main__.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/shopping.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/auth.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/config.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/crossref.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/reddit/write.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/adloop.md +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/runtime.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/tracking.py +0 -0
- {adloop-0.27.1 → adloop-0.28.0}/src/adloop/upload_card.html +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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 ({
|
|
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
|
-
|
|
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 ({
|
|
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(
|
|
416
|
+
_TOOL_TAGS[name] = frozenset(tool_tags)
|
|
397
417
|
return mcp.tool(
|
|
398
418
|
title=title,
|
|
399
419
|
annotations=annotations,
|
|
400
|
-
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
|
|
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)}.
|
|
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
|
|
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
|
|
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
|
|
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
|