adloop 0.6.0__tar.gz → 0.6.2__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 (29) hide show
  1. {adloop-0.6.0 → adloop-0.6.2}/PKG-INFO +7 -2
  2. {adloop-0.6.0 → adloop-0.6.2}/README.md +6 -1
  3. {adloop-0.6.0 → adloop-0.6.2}/pyproject.toml +1 -1
  4. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/__init__.py +1 -1
  5. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/read.py +25 -4
  6. adloop-0.6.2/src/adloop/diagnostics.py +213 -0
  7. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/server.py +47 -11
  8. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/__main__.py +0 -0
  9. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/__init__.py +0 -0
  10. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/client.py +0 -0
  11. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/currency.py +0 -0
  12. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/forecast.py +0 -0
  13. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/gaql.py +0 -0
  14. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/pmax.py +0 -0
  15. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/write.py +0 -0
  16. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/auth.py +0 -0
  17. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/bundled_credentials.json +0 -0
  18. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/cli.py +0 -0
  19. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/config.py +0 -0
  20. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/crossref.py +0 -0
  21. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ga4/__init__.py +0 -0
  22. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ga4/client.py +0 -0
  23. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ga4/reports.py +0 -0
  24. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ga4/tracking.py +0 -0
  25. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/safety/__init__.py +0 -0
  26. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/safety/audit.py +0 -0
  27. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/safety/guards.py +0 -0
  28. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/safety/preview.py +0 -0
  29. {adloop-0.6.0 → adloop-0.6.2}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.6.0
3
+ Version: 0.6.2
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.6.0"
3
+ version = "0.6.2"
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.6.0"
5
+ __version__ = "0.6.2"
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(
@@ -0,0 +1,213 @@
1
+ """Optional diagnostic instrumentation for debugging MCP host disconnects.
2
+
3
+ Activated only when the environment variable ``ADLOOP_DEBUG`` is set to a
4
+ truthy value (``1``, ``true``, ``yes``). Otherwise all hooks are no-ops and
5
+ impose no runtime cost.
6
+
7
+ When enabled, this module emits structured ``[adloop-debug]`` lines to
8
+ **stderr** (never stdout — stdout is the MCP channel). The output is designed
9
+ to answer a specific question: when the MCP server process disappears without
10
+ a Python exception, did it exit via signal, EOF on stdin, or SIGKILL?
11
+
12
+ Emitted events:
13
+
14
+ - ``started`` — once, at server startup, including pid and python version.
15
+ - ``heartbeat`` — every ``ADLOOP_HEARTBEAT_SECONDS`` (default 30s) with uptime,
16
+ time since last tool call, and peak RSS.
17
+ - ``tool_start`` / ``tool_end`` — bracketing every MCP tool invocation, with
18
+ the tool name and duration.
19
+ - ``signal`` — on SIGTERM/SIGHUP/SIGINT/SIGPIPE, immediately before the signal
20
+ propagates to Python's default handler (which then terminates the process).
21
+ - ``atexit`` — if the interpreter shuts down normally.
22
+
23
+ If stderr goes silent between two heartbeats and no ``signal`` or ``atexit``
24
+ line appears, the process was SIGKILL'd or the stdio pipe was torn down
25
+ without giving Python a chance to run handlers. That's the diagnostic signal
26
+ we care about.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import atexit
32
+ import functools
33
+ import os
34
+ import signal
35
+ import sys
36
+ import threading
37
+ import time
38
+ from typing import Callable
39
+
40
+ _ENABLED = os.getenv("ADLOOP_DEBUG", "").lower() in ("1", "true", "yes", "on")
41
+ _HEARTBEAT_SECONDS = int(os.getenv("ADLOOP_HEARTBEAT_SECONDS", "30") or "30")
42
+
43
+ _start_time: float = time.monotonic()
44
+ _last_activity_time: float = time.monotonic()
45
+ _last_activity_label: str = "startup"
46
+ _activity_lock = threading.Lock()
47
+
48
+
49
+ def enabled() -> bool:
50
+ """Return True if debug diagnostics are active."""
51
+ return _ENABLED
52
+
53
+
54
+ def _uptime() -> float:
55
+ return time.monotonic() - _start_time
56
+
57
+
58
+ def _time_since_activity() -> float:
59
+ return time.monotonic() - _last_activity_time
60
+
61
+
62
+ def _rss_mb() -> float | None:
63
+ """Return peak resident set size in MB, or None if unavailable."""
64
+ try:
65
+ import resource
66
+
67
+ rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
68
+ except Exception:
69
+ return None
70
+ # macOS reports bytes, Linux reports kilobytes.
71
+ if sys.platform == "darwin":
72
+ return rss / (1024.0 * 1024.0)
73
+ return rss / 1024.0
74
+
75
+
76
+ def _emit(event: str, **fields: object) -> None:
77
+ """Write a single diagnostic line to stderr (never stdout)."""
78
+ parts = [f"[adloop-debug] event={event}", f"uptime={_uptime():.2f}s"]
79
+ for k, v in fields.items():
80
+ if isinstance(v, float):
81
+ parts.append(f"{k}={v:.2f}")
82
+ else:
83
+ parts.append(f"{k}={v}")
84
+ try:
85
+ sys.stderr.write(" ".join(parts) + "\n")
86
+ sys.stderr.flush()
87
+ except Exception:
88
+ # Stderr might be closed during shutdown — never raise from the logger.
89
+ pass
90
+
91
+
92
+ def mark_activity(label: str) -> None:
93
+ """Record that the server produced or started handling traffic."""
94
+ if not _ENABLED:
95
+ return
96
+ global _last_activity_time, _last_activity_label
97
+ with _activity_lock:
98
+ _last_activity_time = time.monotonic()
99
+ _last_activity_label = label
100
+
101
+
102
+ def wrap_tool(fn: Callable) -> Callable:
103
+ """Wrap an MCP tool callable so its start/end events are logged.
104
+
105
+ No-op when diagnostics are disabled, so there's zero overhead in production.
106
+ """
107
+ if not _ENABLED:
108
+ return fn
109
+
110
+ @functools.wraps(fn)
111
+ def wrapper(*args, **kwargs):
112
+ name = getattr(fn, "__name__", "tool")
113
+ t0 = time.monotonic()
114
+ mark_activity(f"tool_start:{name}")
115
+ _emit("tool_start", tool=name)
116
+ try:
117
+ result = fn(*args, **kwargs)
118
+ return result
119
+ finally:
120
+ dt = time.monotonic() - t0
121
+ mark_activity(f"tool_end:{name}")
122
+ _emit("tool_end", tool=name, duration_s=dt)
123
+
124
+ return wrapper
125
+
126
+
127
+ def _heartbeat_loop() -> None:
128
+ """Daemon thread that emits a periodic liveness line."""
129
+ while True:
130
+ time.sleep(_HEARTBEAT_SECONDS)
131
+ try:
132
+ _emit(
133
+ "heartbeat",
134
+ idle_s=_time_since_activity(),
135
+ last_activity=_last_activity_label,
136
+ rss_mb=_rss_mb() if _rss_mb() is not None else "unknown",
137
+ )
138
+ except Exception:
139
+ return
140
+
141
+
142
+ def _install_signal_handlers() -> None:
143
+ """Log signal receipt, then let Python's default handler take over.
144
+
145
+ We can't meaningfully prevent termination; the goal is only to prove that
146
+ a signal was received (vs. silent SIGKILL or EOF).
147
+ """
148
+ def make_handler(signum: int):
149
+ def handler(_signum, _frame):
150
+ try:
151
+ name = signal.Signals(signum).name
152
+ except Exception:
153
+ name = str(signum)
154
+ _emit(
155
+ "signal",
156
+ name=name,
157
+ idle_s=_time_since_activity(),
158
+ last_activity=_last_activity_label,
159
+ rss_mb=_rss_mb() if _rss_mb() is not None else "unknown",
160
+ )
161
+ # Restore default behavior and re-raise the signal against ourselves
162
+ # so termination semantics (exit code, core dump, etc.) are preserved.
163
+ signal.signal(signum, signal.SIG_DFL)
164
+ os.kill(os.getpid(), signum)
165
+
166
+ return handler
167
+
168
+ for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT, signal.SIGPIPE):
169
+ try:
170
+ signal.signal(sig, make_handler(sig))
171
+ except (ValueError, OSError):
172
+ # Some signals can't be installed from non-main threads or on
173
+ # certain platforms — skip silently.
174
+ pass
175
+
176
+
177
+ def _install_atexit() -> None:
178
+ def _on_exit():
179
+ _emit(
180
+ "atexit",
181
+ idle_s=_time_since_activity(),
182
+ last_activity=_last_activity_label,
183
+ rss_mb=_rss_mb() if _rss_mb() is not None else "unknown",
184
+ )
185
+
186
+ atexit.register(_on_exit)
187
+
188
+
189
+ def install() -> None:
190
+ """Enable all diagnostic hooks. Safe to call multiple times (idempotent)."""
191
+ if not _ENABLED:
192
+ return
193
+
194
+ if getattr(install, "_installed", False):
195
+ return
196
+ install._installed = True # type: ignore[attr-defined]
197
+
198
+ _emit(
199
+ "started",
200
+ pid=os.getpid(),
201
+ python=sys.version.split()[0],
202
+ heartbeat_s=_HEARTBEAT_SECONDS,
203
+ platform=sys.platform,
204
+ )
205
+ _install_signal_handlers()
206
+ _install_atexit()
207
+
208
+ thread = threading.Thread(
209
+ target=_heartbeat_loop,
210
+ name="adloop-debug-heartbeat",
211
+ daemon=True,
212
+ )
213
+ thread.start()
@@ -8,8 +8,11 @@ from typing import Callable
8
8
  from fastmcp import FastMCP
9
9
  from mcp.types import ToolAnnotations
10
10
 
11
+ from adloop import diagnostics
11
12
  from adloop.config import load_config
12
13
 
14
+ diagnostics.install()
15
+
13
16
  _READONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False)
14
17
  _WRITE = ToolAnnotations(readOnlyHint=False, destructiveHint=False)
15
18
  _DESTRUCTIVE = ToolAnnotations(readOnlyHint=False, destructiveHint=True)
@@ -82,7 +85,12 @@ def _structured_error(fn_name: str, exc: Exception) -> dict:
82
85
 
83
86
 
84
87
  def _safe(fn: Callable) -> Callable:
85
- """Wrap a tool function so exceptions return structured error dicts."""
88
+ """Wrap a tool function so exceptions return structured error dicts.
89
+
90
+ When ``ADLOOP_DEBUG`` is set, the resulting callable is additionally
91
+ instrumented via :mod:`adloop.diagnostics` to emit tool_start/tool_end
92
+ events and update the last-activity timestamp.
93
+ """
86
94
 
87
95
  @functools.wraps(fn)
88
96
  def wrapper(*args, **kwargs):
@@ -93,7 +101,7 @@ def _safe(fn: Callable) -> Callable:
93
101
  except Exception as e:
94
102
  return _structured_error(fn.__name__, e)
95
103
 
96
- return wrapper
104
+ return diagnostics.wrap_tool(wrapper)
97
105
 
98
106
  # ---------------------------------------------------------------------------
99
107
  # Health Check
@@ -147,11 +155,21 @@ def health_check() -> dict:
147
155
  status["ga4_error_details"] = parsed["details"]
148
156
 
149
157
  try:
150
- from adloop.ads.read import list_accounts as _ads_test
151
-
152
- result = _ads_test(_config)
158
+ from adloop.ads.gaql import execute_query
159
+
160
+ # Minimal probe — one row is enough to confirm OAuth, developer token,
161
+ # and API reachability. We deliberately avoid enumerating customer_client
162
+ # here: on large MCCs (100+ accounts) that call can take multiple seconds
163
+ # and its size/latency is the likely culprit when the MCP host kills the
164
+ # connection shortly after health_check. Call list_accounts explicitly
165
+ # if a count or listing is actually needed.
166
+ mcc_id = _config.ads.login_customer_id or _config.ads.customer_id
167
+ execute_query(
168
+ _config,
169
+ mcc_id,
170
+ "SELECT customer.id, customer.descriptive_name FROM customer LIMIT 1",
171
+ )
153
172
  status["ads"] = "ok"
154
- status["ads_accounts"] = result.get("total_accounts", 0)
155
173
  except Exception as e:
156
174
  parsed = _structured_error("health_check", e)
157
175
  status["ads"] = "error"
@@ -273,15 +291,17 @@ def get_tracking_events(
273
291
 
274
292
  @mcp.tool(annotations=_READONLY)
275
293
  @_safe
276
- def list_accounts() -> dict:
277
- """List all accessible Google Ads accounts.
294
+ def list_accounts(limit: int = 50) -> dict:
295
+ """List accessible Google Ads accounts.
278
296
 
279
- Returns account names, IDs, and status. Use this to discover
280
- which accounts are available before running performance queries.
297
+ Returns account names, IDs, and status. The default cap of 50 keeps the
298
+ response small on large agency MCCs raise *limit* if you actually need
299
+ more. For most workflows you don't need to list accounts at all: pass
300
+ customer_id directly to get_campaign_performance, run_gaql, etc.
281
301
  """
282
302
  from adloop.ads.read import list_accounts as _impl
283
303
 
284
- return _impl(_config)
304
+ return _impl(_config, limit=limit)
285
305
 
286
306
 
287
307
  @mcp.tool(annotations=_READONLY)
@@ -1367,3 +1387,19 @@ def discover_keywords(
1367
1387
  page_size=page_size,
1368
1388
  customer_id=customer_id or _config.ads.customer_id,
1369
1389
  )
1390
+
1391
+
1392
+ # ---------------------------------------------------------------------------
1393
+ # Optional local-only debug tools (not shipped in git).
1394
+ # ---------------------------------------------------------------------------
1395
+ # Activated by ``ADLOOP_DEBUG_TOOLS=1``. The module file is .gitignored and
1396
+ # only present on developer machines doing MCP-host stress testing.
1397
+
1398
+ import os as _os # noqa: E402
1399
+
1400
+ if _os.getenv("ADLOOP_DEBUG_TOOLS", "").lower() in ("1", "true", "yes", "on"):
1401
+ try:
1402
+ from adloop import _debug_tools # noqa: F401
1403
+ except ImportError:
1404
+ # _debug_tools.py is intentionally absent in released builds.
1405
+ pass
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