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.
- {adloop-0.6.0 → adloop-0.6.2}/PKG-INFO +7 -2
- {adloop-0.6.0 → adloop-0.6.2}/README.md +6 -1
- {adloop-0.6.0 → adloop-0.6.2}/pyproject.toml +1 -1
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/__init__.py +1 -1
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/read.py +25 -4
- adloop-0.6.2/src/adloop/diagnostics.py +213 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/server.py +47 -11
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/__main__.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/client.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/currency.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ads/write.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/auth.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/bundled_credentials.json +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/cli.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/config.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/crossref.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ga4/client.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/safety/audit.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/safety/guards.py +0 -0
- {adloop-0.6.0 → adloop-0.6.2}/src/adloop/safety/preview.py +0 -0
- {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.
|
|
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
|
|
@@ -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(
|
|
@@ -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.
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
294
|
+
def list_accounts(limit: int = 50) -> dict:
|
|
295
|
+
"""List accessible Google Ads accounts.
|
|
278
296
|
|
|
279
|
-
Returns account names, IDs, and status.
|
|
280
|
-
|
|
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
|
|
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
|