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.
Files changed (28) hide show
  1. {adloop-0.5.2 → adloop-0.6.1}/PKG-INFO +7 -2
  2. {adloop-0.5.2 → adloop-0.6.1}/README.md +6 -1
  3. {adloop-0.5.2 → adloop-0.6.1}/pyproject.toml +1 -1
  4. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/__init__.py +1 -1
  5. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/read.py +32 -5
  6. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/write.py +135 -3
  7. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/server.py +57 -10
  8. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/__main__.py +0 -0
  9. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/__init__.py +0 -0
  10. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/client.py +0 -0
  11. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/currency.py +0 -0
  12. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/forecast.py +0 -0
  13. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/gaql.py +0 -0
  14. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ads/pmax.py +0 -0
  15. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/auth.py +0 -0
  16. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/bundled_credentials.json +0 -0
  17. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/cli.py +0 -0
  18. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/config.py +0 -0
  19. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/crossref.py +0 -0
  20. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ga4/__init__.py +0 -0
  21. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ga4/client.py +0 -0
  22. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ga4/reports.py +0 -0
  23. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/ga4/tracking.py +0 -0
  24. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/safety/__init__.py +0 -0
  25. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/safety/audit.py +0 -0
  26. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/safety/guards.py +0 -0
  27. {adloop-0.5.2 → adloop-0.6.1}/src/adloop/safety/preview.py +0 -0
  28. {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.5.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.5.2"
3
+ version = "0.6.1"
4
4
  description = "Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- __version__ = "0.5.2"
5
+ __version__ = "0.6.1"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -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 all accessible Google Ads accounts."""
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
- return {"accounts": rows, "total_accounts": len(rows)}
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.keyword.text,
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 (keyword, negative_keyword, ad, ad_group, campaign).
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
- For keywords and negative keywords, this fully deletes the criterion.
491
- Returns a preview; call confirm_and_apply to execute.
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.read import list_accounts as _ads_test
151
-
152
- result = _ads_test(_config)
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 all accessible Google Ads accounts.
286
+ def list_accounts(limit: int = 50) -> dict:
287
+ """List accessible Google Ads accounts.
278
288
 
279
- Returns account names, IDs, and status. Use this to discover
280
- which accounts are available before running performance queries.
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