adloop 0.5.2__tar.gz → 0.6.1__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.5.2 → adloop-0.6.1}/PKG-INFO +7 -2
- {adloop-0.5.2 → adloop-0.6.1}/README.md +6 -1
- {adloop-0.5.2 → adloop-0.6.1}/pyproject.toml +1 -1
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/__init__.py +1 -1
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/read.py +32 -5
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/write.py +135 -3
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/server.py +57 -10
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/__main__.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/client.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/currency.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/auth.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/bundled_credentials.json +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/cli.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/config.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/crossref.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ga4/client.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/safety/audit.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/safety/guards.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/safety/preview.py +0 -0
- {adloop-0.5.2 → adloop-0.6.1}/src/adloop/tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.
|
|
5
5
|
Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
@@ -216,6 +216,11 @@ uv run adloop init
|
|
|
216
216
|
|
|
217
217
|
The `adloop init` wizard walks you through everything. AdLoop ships with built-in Google OAuth credentials, so you don't need to create a Google Cloud project.
|
|
218
218
|
|
|
219
|
+
> **⚠️ Built-in credentials temporarily unavailable — Google verification pending.**
|
|
220
|
+
> Google limits unverified OAuth apps to 100 users. AdLoop has reached that cap while awaiting Google's app verification. Until verification is complete, the built-in credentials will show a **"This app is blocked"** error for new users.
|
|
221
|
+
>
|
|
222
|
+
> **Workaround:** set up your own Google Cloud project using the [Advanced Setup](#advanced-setup-custom-google-cloud-project) instructions below (takes ~5 minutes). Your own project has no user cap and is the recommended setup path in the meantime.
|
|
223
|
+
|
|
219
224
|
The wizard:
|
|
220
225
|
|
|
221
226
|
1. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
|
|
@@ -390,7 +395,7 @@ What's been shipped and what's next:
|
|
|
390
395
|
- ~~Setup wizard (`adloop init`)~~ ✓
|
|
391
396
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
392
397
|
- ~~PyPI package~~ ✓ — `pip install adloop`
|
|
393
|
-
- ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required, auto-discovery of GA4/Ads accounts
|
|
398
|
+
- ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required, auto-discovery of GA4/Ads accounts (currently capped at 100 users pending Google verification — use [Advanced Setup](#advanced-setup-custom-google-cloud-project) in the meantime)
|
|
394
399
|
- ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
|
|
395
400
|
- ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
|
|
396
401
|
- **Community launch** — HN, Indie Hackers, r/cursor, Twitter
|
|
@@ -192,6 +192,11 @@ uv run adloop init
|
|
|
192
192
|
|
|
193
193
|
The `adloop init` wizard walks you through everything. AdLoop ships with built-in Google OAuth credentials, so you don't need to create a Google Cloud project.
|
|
194
194
|
|
|
195
|
+
> **⚠️ Built-in credentials temporarily unavailable — Google verification pending.**
|
|
196
|
+
> Google limits unverified OAuth apps to 100 users. AdLoop has reached that cap while awaiting Google's app verification. Until verification is complete, the built-in credentials will show a **"This app is blocked"** error for new users.
|
|
197
|
+
>
|
|
198
|
+
> **Workaround:** set up your own Google Cloud project using the [Advanced Setup](#advanced-setup-custom-google-cloud-project) instructions below (takes ~5 minutes). Your own project has no user cap and is the recommended setup path in the meantime.
|
|
199
|
+
|
|
195
200
|
The wizard:
|
|
196
201
|
|
|
197
202
|
1. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
|
|
@@ -366,7 +371,7 @@ What's been shipped and what's next:
|
|
|
366
371
|
- ~~Setup wizard (`adloop init`)~~ ✓
|
|
367
372
|
- ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
|
|
368
373
|
- ~~PyPI package~~ ✓ — `pip install adloop`
|
|
369
|
-
- ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required, auto-discovery of GA4/Ads accounts
|
|
374
|
+
- ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required, auto-discovery of GA4/Ads accounts (currently capped at 100 users pending Google verification — use [Advanced Setup](#advanced-setup-custom-google-cloud-project) in the meantime)
|
|
370
375
|
- ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
|
|
371
376
|
- ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
|
|
372
377
|
- **Community launch** — HN, Indie Hackers, r/cursor, Twitter
|
|
@@ -10,16 +10,25 @@ if TYPE_CHECKING:
|
|
|
10
10
|
from adloop.config import AdLoopConfig
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def list_accounts(config: AdLoopConfig) -> dict:
|
|
14
|
-
"""List
|
|
13
|
+
def list_accounts(config: AdLoopConfig, *, limit: int = 50) -> dict:
|
|
14
|
+
"""List accessible Google Ads accounts, up to *limit* entries.
|
|
15
|
+
|
|
16
|
+
The default of 50 is intentionally conservative: on large agency MCCs
|
|
17
|
+
(100+ accounts) returning the full list as a single MCP tool response
|
|
18
|
+
can trip per-response timeouts or size caps on some MCP hosts. Raise
|
|
19
|
+
*limit* when you explicitly want more, or use the customer_id parameter
|
|
20
|
+
on individual tools (get_campaign_performance, run_gaql, etc.) to query
|
|
21
|
+
a specific account directly without enumerating all of them.
|
|
22
|
+
"""
|
|
15
23
|
from adloop.ads.gaql import execute_query
|
|
16
24
|
|
|
17
25
|
mcc_id = config.ads.login_customer_id
|
|
18
26
|
if mcc_id:
|
|
19
|
-
query = """
|
|
27
|
+
query = f"""
|
|
20
28
|
SELECT customer_client.id, customer_client.descriptive_name,
|
|
21
29
|
customer_client.status, customer_client.manager
|
|
22
30
|
FROM customer_client
|
|
31
|
+
LIMIT {int(limit) + 1}
|
|
23
32
|
"""
|
|
24
33
|
rows = execute_query(config, mcc_id, query)
|
|
25
34
|
else:
|
|
@@ -31,7 +40,19 @@ def list_accounts(config: AdLoopConfig) -> dict:
|
|
|
31
40
|
"""
|
|
32
41
|
rows = execute_query(config, config.ads.customer_id, query)
|
|
33
42
|
|
|
34
|
-
|
|
43
|
+
truncated = len(rows) > limit
|
|
44
|
+
if truncated:
|
|
45
|
+
rows = rows[:limit]
|
|
46
|
+
|
|
47
|
+
result: dict = {"accounts": rows, "total_accounts": len(rows)}
|
|
48
|
+
if truncated:
|
|
49
|
+
result["truncated"] = True
|
|
50
|
+
result["note"] = (
|
|
51
|
+
f"Returned the first {limit} accounts. Call list_accounts with a higher "
|
|
52
|
+
f"limit to see more, or pass customer_id directly to other tools to "
|
|
53
|
+
f"query a specific account without enumerating all of them."
|
|
54
|
+
)
|
|
55
|
+
return result
|
|
35
56
|
|
|
36
57
|
|
|
37
58
|
def get_campaign_performance(
|
|
@@ -267,7 +288,8 @@ def get_negative_keyword_list_keywords(
|
|
|
267
288
|
return {"error": "shared_set_id must be a numeric ID"}
|
|
268
289
|
|
|
269
290
|
query = f"""
|
|
270
|
-
SELECT shared_criterion.
|
|
291
|
+
SELECT shared_criterion.criterion_id,
|
|
292
|
+
shared_criterion.keyword.text,
|
|
271
293
|
shared_criterion.keyword.match_type,
|
|
272
294
|
shared_criterion.type,
|
|
273
295
|
shared_set.id, shared_set.name
|
|
@@ -277,6 +299,11 @@ def get_negative_keyword_list_keywords(
|
|
|
277
299
|
"""
|
|
278
300
|
|
|
279
301
|
rows = execute_query(config, customer_id, query)
|
|
302
|
+
for row in rows:
|
|
303
|
+
ssid = row.get("shared_set.id")
|
|
304
|
+
crit_id = row.get("shared_criterion.criterion_id")
|
|
305
|
+
if ssid and crit_id:
|
|
306
|
+
row["resource_id"] = f"{ssid}~{crit_id}"
|
|
280
307
|
return {
|
|
281
308
|
"keywords": rows,
|
|
282
309
|
"total_keywords": len(rows),
|
|
@@ -396,6 +396,77 @@ def propose_negative_keyword_list(
|
|
|
396
396
|
return plan.to_preview()
|
|
397
397
|
|
|
398
398
|
|
|
399
|
+
def add_to_negative_keyword_list(
|
|
400
|
+
config: AdLoopConfig,
|
|
401
|
+
*,
|
|
402
|
+
customer_id: str = "",
|
|
403
|
+
shared_set_id: str = "",
|
|
404
|
+
keywords: list[str] | None = None,
|
|
405
|
+
match_type: str = "EXACT",
|
|
406
|
+
) -> dict:
|
|
407
|
+
"""Draft adding keywords to an existing shared negative keyword list — returns PREVIEW.
|
|
408
|
+
|
|
409
|
+
Unlike ``propose_negative_keyword_list`` (which creates a NEW list), this
|
|
410
|
+
appends keywords to an existing SharedSet identified by ``shared_set_id``.
|
|
411
|
+
Use ``get_negative_keyword_lists`` to find the list's ID. Call
|
|
412
|
+
``confirm_and_apply`` with the returned plan_id to execute.
|
|
413
|
+
"""
|
|
414
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
415
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
check_blocked_operation("add_to_negative_keyword_list", config.safety)
|
|
419
|
+
except SafetyViolation as e:
|
|
420
|
+
return {"error": str(e)}
|
|
421
|
+
|
|
422
|
+
keywords = keywords or []
|
|
423
|
+
match_type = match_type.upper()
|
|
424
|
+
|
|
425
|
+
errors = []
|
|
426
|
+
if not shared_set_id:
|
|
427
|
+
errors.append("shared_set_id is required")
|
|
428
|
+
elif not str(shared_set_id).isdigit():
|
|
429
|
+
errors.append("shared_set_id must be a numeric ID (from get_negative_keyword_lists)")
|
|
430
|
+
if not keywords:
|
|
431
|
+
errors.append("At least one keyword is required")
|
|
432
|
+
if match_type not in _VALID_MATCH_TYPES:
|
|
433
|
+
errors.append(f"Invalid match_type '{match_type}' — use EXACT, PHRASE, or BROAD")
|
|
434
|
+
if errors:
|
|
435
|
+
return {"error": "Validation failed", "details": errors}
|
|
436
|
+
|
|
437
|
+
seen: set[str] = set()
|
|
438
|
+
deduped: list[str] = []
|
|
439
|
+
for kw in keywords:
|
|
440
|
+
text = kw.strip()
|
|
441
|
+
if not text:
|
|
442
|
+
continue
|
|
443
|
+
key = text.lower()
|
|
444
|
+
if key in seen:
|
|
445
|
+
continue
|
|
446
|
+
seen.add(key)
|
|
447
|
+
deduped.append(text)
|
|
448
|
+
|
|
449
|
+
if not deduped:
|
|
450
|
+
return {
|
|
451
|
+
"error": "Validation failed",
|
|
452
|
+
"details": ["At least one non-empty keyword is required"],
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
plan = ChangePlan(
|
|
456
|
+
operation="add_to_negative_keyword_list",
|
|
457
|
+
entity_type="negative_keyword_list",
|
|
458
|
+
entity_id=str(shared_set_id),
|
|
459
|
+
customer_id=customer_id,
|
|
460
|
+
changes={
|
|
461
|
+
"shared_set_id": str(shared_set_id),
|
|
462
|
+
"keywords": deduped,
|
|
463
|
+
"match_type": match_type,
|
|
464
|
+
},
|
|
465
|
+
)
|
|
466
|
+
store_plan(plan)
|
|
467
|
+
return plan.to_preview()
|
|
468
|
+
|
|
469
|
+
|
|
399
470
|
def update_ad_group(
|
|
400
471
|
config: AdLoopConfig,
|
|
401
472
|
*,
|
|
@@ -484,11 +555,26 @@ def remove_entity(
|
|
|
484
555
|
entity_type: str = "",
|
|
485
556
|
entity_id: str = "",
|
|
486
557
|
) -> dict:
|
|
487
|
-
"""Draft removing an entity
|
|
558
|
+
"""Draft removing an entity — returns preview.
|
|
559
|
+
|
|
560
|
+
Supported ``entity_type`` values: ``campaign``, ``ad_group``, ``ad``,
|
|
561
|
+
``keyword``, ``negative_keyword``, ``shared_criterion``, ``campaign_asset``,
|
|
562
|
+
``asset``, ``customer_asset``.
|
|
563
|
+
|
|
564
|
+
Composite ``entity_id`` formats:
|
|
565
|
+
|
|
566
|
+
- ``keyword``: ``adGroupId~criterionId``
|
|
567
|
+
- ``negative_keyword``: ``campaignId~criterionId`` (use the ``resource_id``
|
|
568
|
+
field from ``get_negative_keywords``)
|
|
569
|
+
- ``shared_criterion``: ``sharedSetId~criterionId`` (use the ``resource_id``
|
|
570
|
+
field from ``get_negative_keyword_list_keywords``)
|
|
571
|
+
- ``campaign_asset``: ``campaignId~assetId~fieldType``
|
|
572
|
+
- ``customer_asset``: ``assetId~fieldType``
|
|
573
|
+
- ``asset``: bare asset ID
|
|
488
574
|
|
|
489
575
|
This is a DESTRUCTIVE operation — removed entities cannot be re-enabled.
|
|
490
|
-
|
|
491
|
-
|
|
576
|
+
Prefer ``pause_entity`` unless the user explicitly wants permanent removal.
|
|
577
|
+
Call ``confirm_and_apply`` with the returned plan_id to execute.
|
|
492
578
|
"""
|
|
493
579
|
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
494
580
|
from adloop.safety.preview import ChangePlan, store_plan
|
|
@@ -1167,6 +1253,7 @@ _VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"}
|
|
|
1167
1253
|
_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"}
|
|
1168
1254
|
_REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | {
|
|
1169
1255
|
"negative_keyword", "campaign_asset", "asset", "customer_asset",
|
|
1256
|
+
"shared_criterion",
|
|
1170
1257
|
}
|
|
1171
1258
|
|
|
1172
1259
|
_SMART_BIDDING_STRATEGIES = {
|
|
@@ -1726,6 +1813,7 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
|
|
|
1726
1813
|
"add_keywords": _apply_add_keywords,
|
|
1727
1814
|
"add_negative_keywords": _apply_add_negative_keywords,
|
|
1728
1815
|
"create_negative_keyword_list": _apply_create_negative_keyword_list,
|
|
1816
|
+
"add_to_negative_keyword_list": _apply_add_to_negative_keyword_list,
|
|
1729
1817
|
"pause_entity": _apply_status_change,
|
|
1730
1818
|
"enable_entity": _apply_status_change,
|
|
1731
1819
|
"remove_entity": _apply_remove,
|
|
@@ -2282,6 +2370,19 @@ def _apply_remove(
|
|
|
2282
2370
|
customer_id=cid, operations=[operation]
|
|
2283
2371
|
)
|
|
2284
2372
|
|
|
2373
|
+
elif entity_type == "shared_criterion":
|
|
2374
|
+
if "~" not in entity_id:
|
|
2375
|
+
raise ValueError(
|
|
2376
|
+
f"shared_criterion entity_id must be "
|
|
2377
|
+
f"'sharedSetId~criterionId', got '{entity_id}'"
|
|
2378
|
+
)
|
|
2379
|
+
service = client.get_service("SharedCriterionService")
|
|
2380
|
+
operation = client.get_type("SharedCriterionOperation")
|
|
2381
|
+
operation.remove = f"customers/{cid}/sharedCriteria/{entity_id}"
|
|
2382
|
+
response = service.mutate_shared_criteria(
|
|
2383
|
+
customer_id=cid, operations=[operation]
|
|
2384
|
+
)
|
|
2385
|
+
|
|
2285
2386
|
elif entity_type == "campaign_asset":
|
|
2286
2387
|
parts = entity_id.split("~")
|
|
2287
2388
|
if len(parts) != 3:
|
|
@@ -2599,3 +2700,34 @@ def _apply_create_negative_keyword_list(
|
|
|
2599
2700
|
"campaign_shared_set_resource": css_response.results[0].resource_name,
|
|
2600
2701
|
"keyword_count": len(changes["keywords"]),
|
|
2601
2702
|
}
|
|
2703
|
+
|
|
2704
|
+
|
|
2705
|
+
def _apply_add_to_negative_keyword_list(
|
|
2706
|
+
client: object, cid: str, changes: dict
|
|
2707
|
+
) -> dict:
|
|
2708
|
+
"""Append keywords to an existing shared negative keyword list."""
|
|
2709
|
+
shared_set_service = client.get_service("SharedSetService")
|
|
2710
|
+
shared_set_resource = shared_set_service.shared_set_path(
|
|
2711
|
+
cid, changes["shared_set_id"]
|
|
2712
|
+
)
|
|
2713
|
+
|
|
2714
|
+
sc_service = client.get_service("SharedCriterionService")
|
|
2715
|
+
operations = []
|
|
2716
|
+
for kw_text in changes["keywords"]:
|
|
2717
|
+
op = client.get_type("SharedCriterionOperation")
|
|
2718
|
+
criterion = op.create
|
|
2719
|
+
criterion.shared_set = shared_set_resource
|
|
2720
|
+
criterion.keyword.text = kw_text
|
|
2721
|
+
criterion.keyword.match_type = getattr(
|
|
2722
|
+
client.enums.KeywordMatchTypeEnum, changes["match_type"]
|
|
2723
|
+
)
|
|
2724
|
+
operations.append(op)
|
|
2725
|
+
|
|
2726
|
+
response = sc_service.mutate_shared_criteria(
|
|
2727
|
+
customer_id=cid, operations=operations
|
|
2728
|
+
)
|
|
2729
|
+
return {
|
|
2730
|
+
"shared_set_resource": shared_set_resource,
|
|
2731
|
+
"resource_names": [r.resource_name for r in response.results],
|
|
2732
|
+
"keyword_count": len(response.results),
|
|
2733
|
+
}
|
|
@@ -147,11 +147,21 @@ def health_check() -> dict:
|
|
|
147
147
|
status["ga4_error_details"] = parsed["details"]
|
|
148
148
|
|
|
149
149
|
try:
|
|
150
|
-
from adloop.ads.
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
from adloop.ads.gaql import execute_query
|
|
151
|
+
|
|
152
|
+
# Minimal probe — one row is enough to confirm OAuth, developer token,
|
|
153
|
+
# and API reachability. We deliberately avoid enumerating customer_client
|
|
154
|
+
# here: on large MCCs (100+ accounts) that call can take multiple seconds
|
|
155
|
+
# and its size/latency is the likely culprit when the MCP host kills the
|
|
156
|
+
# connection shortly after health_check. Call list_accounts explicitly
|
|
157
|
+
# if a count or listing is actually needed.
|
|
158
|
+
mcc_id = _config.ads.login_customer_id or _config.ads.customer_id
|
|
159
|
+
execute_query(
|
|
160
|
+
_config,
|
|
161
|
+
mcc_id,
|
|
162
|
+
"SELECT customer.id, customer.descriptive_name FROM customer LIMIT 1",
|
|
163
|
+
)
|
|
153
164
|
status["ads"] = "ok"
|
|
154
|
-
status["ads_accounts"] = result.get("total_accounts", 0)
|
|
155
165
|
except Exception as e:
|
|
156
166
|
parsed = _structured_error("health_check", e)
|
|
157
167
|
status["ads"] = "error"
|
|
@@ -273,15 +283,17 @@ def get_tracking_events(
|
|
|
273
283
|
|
|
274
284
|
@mcp.tool(annotations=_READONLY)
|
|
275
285
|
@_safe
|
|
276
|
-
def list_accounts() -> dict:
|
|
277
|
-
"""List
|
|
286
|
+
def list_accounts(limit: int = 50) -> dict:
|
|
287
|
+
"""List accessible Google Ads accounts.
|
|
278
288
|
|
|
279
|
-
Returns account names, IDs, and status.
|
|
280
|
-
|
|
289
|
+
Returns account names, IDs, and status. The default cap of 50 keeps the
|
|
290
|
+
response small on large agency MCCs — raise *limit* if you actually need
|
|
291
|
+
more. For most workflows you don't need to list accounts at all: pass
|
|
292
|
+
customer_id directly to get_campaign_performance, run_gaql, etc.
|
|
281
293
|
"""
|
|
282
294
|
from adloop.ads.read import list_accounts as _impl
|
|
283
295
|
|
|
284
|
-
return _impl(_config)
|
|
296
|
+
return _impl(_config, limit=limit)
|
|
285
297
|
|
|
286
298
|
|
|
287
299
|
@mcp.tool(annotations=_READONLY)
|
|
@@ -979,6 +991,39 @@ def propose_negative_keyword_list(
|
|
|
979
991
|
)
|
|
980
992
|
|
|
981
993
|
|
|
994
|
+
@mcp.tool(annotations=_WRITE)
|
|
995
|
+
@_safe
|
|
996
|
+
def add_to_negative_keyword_list(
|
|
997
|
+
shared_set_id: str,
|
|
998
|
+
keywords: list[str],
|
|
999
|
+
customer_id: str = "",
|
|
1000
|
+
match_type: str = "EXACT",
|
|
1001
|
+
) -> dict:
|
|
1002
|
+
"""Append keywords to an EXISTING shared negative keyword list — returns a PREVIEW.
|
|
1003
|
+
|
|
1004
|
+
Use this when a suitable list already exists and only needs more keywords
|
|
1005
|
+
(instead of propose_negative_keyword_list, which creates a new list).
|
|
1006
|
+
Always call get_negative_keyword_lists first to find the right shared_set_id
|
|
1007
|
+
and get_negative_keyword_list_keywords to avoid duplicating existing terms.
|
|
1008
|
+
|
|
1009
|
+
shared_set_id: numeric ID from get_negative_keyword_lists (shared_set.id).
|
|
1010
|
+
keywords: list of keyword strings to append (duplicates in the input list
|
|
1011
|
+
are collapsed).
|
|
1012
|
+
match_type: "EXACT", "PHRASE", or "BROAD"
|
|
1013
|
+
|
|
1014
|
+
Call confirm_and_apply with the returned plan_id to execute.
|
|
1015
|
+
"""
|
|
1016
|
+
from adloop.ads.write import add_to_negative_keyword_list as _impl
|
|
1017
|
+
|
|
1018
|
+
return _impl(
|
|
1019
|
+
_config,
|
|
1020
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
1021
|
+
shared_set_id=shared_set_id,
|
|
1022
|
+
keywords=keywords,
|
|
1023
|
+
match_type=match_type,
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
982
1027
|
@mcp.tool(annotations=_WRITE)
|
|
983
1028
|
@_safe
|
|
984
1029
|
def update_ad_group(
|
|
@@ -1119,11 +1164,13 @@ def remove_entity(
|
|
|
1119
1164
|
"""Draft REMOVING an entity — returns a PREVIEW. This is IRREVERSIBLE.
|
|
1120
1165
|
|
|
1121
1166
|
entity_type: "campaign", "ad_group", "ad", "keyword", "negative_keyword",
|
|
1122
|
-
"campaign_asset", "asset", or "customer_asset"
|
|
1167
|
+
"shared_criterion", "campaign_asset", "asset", or "customer_asset"
|
|
1123
1168
|
entity_id: The resource ID.
|
|
1124
1169
|
For keywords: "adGroupId~criterionId"
|
|
1125
1170
|
For negative_keywords: "campaignId~criterionId"
|
|
1126
1171
|
(use the resource_id field from get_negative_keywords)
|
|
1172
|
+
For shared_criterion: "sharedSetId~criterionId"
|
|
1173
|
+
(use the resource_id field from get_negative_keyword_list_keywords)
|
|
1127
1174
|
For campaign_asset: "campaignId~assetId~fieldType"
|
|
1128
1175
|
For asset: simple asset ID
|
|
1129
1176
|
For customer_asset: "assetId~fieldType"
|
|
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
|