adloop 0.1.0__py3-none-any.whl
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/__init__.py +21 -0
- adloop/__main__.py +5 -0
- adloop/ads/__init__.py +0 -0
- adloop/ads/client.py +41 -0
- adloop/ads/forecast.py +156 -0
- adloop/ads/gaql.py +178 -0
- adloop/ads/read.py +238 -0
- adloop/ads/write.py +950 -0
- adloop/auth.py +132 -0
- adloop/cli.py +375 -0
- adloop/config.py +102 -0
- adloop/crossref.py +509 -0
- adloop/ga4/__init__.py +0 -0
- adloop/ga4/client.py +31 -0
- adloop/ga4/reports.py +141 -0
- adloop/ga4/tracking.py +36 -0
- adloop/safety/__init__.py +0 -0
- adloop/safety/audit.py +40 -0
- adloop/safety/guards.py +56 -0
- adloop/safety/preview.py +58 -0
- adloop/server.py +778 -0
- adloop/tracking.py +244 -0
- adloop-0.1.0.dist-info/METADATA +382 -0
- adloop-0.1.0.dist-info/RECORD +26 -0
- adloop-0.1.0.dist-info/WHEEL +4 -0
- adloop-0.1.0.dist-info/entry_points.txt +3 -0
adloop/server.py
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
"""AdLoop MCP server — FastMCP instance with all tool registrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
from mcp.types import ToolAnnotations
|
|
10
|
+
|
|
11
|
+
from adloop.config import load_config
|
|
12
|
+
|
|
13
|
+
_READONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False)
|
|
14
|
+
_WRITE = ToolAnnotations(readOnlyHint=False, destructiveHint=False)
|
|
15
|
+
_DESTRUCTIVE = ToolAnnotations(readOnlyHint=False, destructiveHint=True)
|
|
16
|
+
|
|
17
|
+
mcp = FastMCP(
|
|
18
|
+
"AdLoop",
|
|
19
|
+
instructions=(
|
|
20
|
+
"AdLoop connects Google Ads and Google Analytics (GA4) data to your "
|
|
21
|
+
"codebase. Use the read tools to analyze performance, and the write "
|
|
22
|
+
"tools (with safety confirmation) to manage campaigns."
|
|
23
|
+
),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_config = load_config()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _safe(fn: Callable) -> Callable:
|
|
30
|
+
"""Wrap a tool function so exceptions return structured error dicts."""
|
|
31
|
+
|
|
32
|
+
@functools.wraps(fn)
|
|
33
|
+
def wrapper(*args, **kwargs):
|
|
34
|
+
try:
|
|
35
|
+
return fn(*args, **kwargs)
|
|
36
|
+
except RuntimeError as e:
|
|
37
|
+
return {"error": str(e)}
|
|
38
|
+
except Exception as e:
|
|
39
|
+
err = str(e).lower()
|
|
40
|
+
if "invalid_grant" in err or "revoked" in err:
|
|
41
|
+
return {
|
|
42
|
+
"error": "Authentication failed — OAuth token expired or revoked.",
|
|
43
|
+
"hint": (
|
|
44
|
+
"Delete ~/.adloop/token.json and re-run any tool to "
|
|
45
|
+
"trigger re-authorization. If this keeps happening, "
|
|
46
|
+
"publish the GCP consent screen to 'In production'."
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
return {"error": str(e), "tool": fn.__name__}
|
|
50
|
+
|
|
51
|
+
return wrapper
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Health Check
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mcp.tool(annotations=_READONLY)
|
|
59
|
+
@_safe
|
|
60
|
+
def health_check() -> dict:
|
|
61
|
+
"""Test AdLoop connectivity — checks OAuth token, GA4 API, and Google Ads API.
|
|
62
|
+
|
|
63
|
+
Run this first if other tools are failing. Returns status for each service
|
|
64
|
+
and actionable guidance if something is broken.
|
|
65
|
+
"""
|
|
66
|
+
from adloop.ads.client import GOOGLE_ADS_API_VERSION
|
|
67
|
+
|
|
68
|
+
status = {
|
|
69
|
+
"ga4": "unknown",
|
|
70
|
+
"ads": "unknown",
|
|
71
|
+
"config": "ok",
|
|
72
|
+
"google_ads_api_version": GOOGLE_ADS_API_VERSION,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
from google.ads.googleads.client import _DEFAULT_VERSION
|
|
77
|
+
if _DEFAULT_VERSION != GOOGLE_ADS_API_VERSION:
|
|
78
|
+
status["ads_version_note"] = (
|
|
79
|
+
f"AdLoop is pinned to {GOOGLE_ADS_API_VERSION} but the "
|
|
80
|
+
f"google-ads library defaults to {_DEFAULT_VERSION}. "
|
|
81
|
+
f"A newer API version is available — update "
|
|
82
|
+
f"GOOGLE_ADS_API_VERSION in ads/client.py when ready to migrate."
|
|
83
|
+
)
|
|
84
|
+
except ImportError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
from adloop.ga4.reports import get_account_summaries as _ga4_test
|
|
89
|
+
|
|
90
|
+
result = _ga4_test(_config)
|
|
91
|
+
status["ga4"] = "ok"
|
|
92
|
+
status["ga4_properties"] = result.get("total_properties", 0)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
status["ga4"] = "error"
|
|
95
|
+
status["ga4_error"] = str(e)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
from adloop.ads.read import list_accounts as _ads_test
|
|
99
|
+
|
|
100
|
+
result = _ads_test(_config)
|
|
101
|
+
status["ads"] = "ok"
|
|
102
|
+
status["ads_accounts"] = result.get("total_accounts", 0)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
status["ads"] = "error"
|
|
105
|
+
status["ads_error"] = str(e)
|
|
106
|
+
|
|
107
|
+
if status["ga4"] == "error" or status["ads"] == "error":
|
|
108
|
+
any_error = status.get("ga4_error", "") + status.get("ads_error", "")
|
|
109
|
+
if "invalid_grant" in any_error.lower() or "revoked" in any_error.lower():
|
|
110
|
+
status["hint"] = (
|
|
111
|
+
"OAuth token expired or revoked. Delete ~/.adloop/token.json "
|
|
112
|
+
"and re-run health_check to trigger re-authorization. "
|
|
113
|
+
"To prevent recurring expiry, publish the GCP consent screen "
|
|
114
|
+
"from 'Testing' to 'In production'."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return status
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# GA4 Read Tools
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@mcp.tool(annotations=_READONLY)
|
|
126
|
+
@_safe
|
|
127
|
+
def get_account_summaries() -> dict:
|
|
128
|
+
"""List all GA4 accounts and properties accessible by the authenticated user.
|
|
129
|
+
|
|
130
|
+
Use this as the first step to discover which GA4 properties are available.
|
|
131
|
+
Returns account names, property names, and property IDs.
|
|
132
|
+
"""
|
|
133
|
+
from adloop.ga4.reports import get_account_summaries as _impl
|
|
134
|
+
|
|
135
|
+
return _impl(_config)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@mcp.tool(annotations=_READONLY)
|
|
139
|
+
@_safe
|
|
140
|
+
def run_ga4_report(
|
|
141
|
+
dimensions: list[str] | None = None,
|
|
142
|
+
metrics: list[str] | None = None,
|
|
143
|
+
date_range_start: str = "7daysAgo",
|
|
144
|
+
date_range_end: str = "today",
|
|
145
|
+
property_id: str = "",
|
|
146
|
+
limit: int = 100,
|
|
147
|
+
) -> dict:
|
|
148
|
+
"""Run a custom GA4 report with specified dimensions, metrics, and date range.
|
|
149
|
+
|
|
150
|
+
Common dimensions: date, pagePath, sessionSource, sessionMedium, country, deviceCategory, eventName
|
|
151
|
+
Common metrics: sessions, totalUsers, newUsers, screenPageViews, conversions, eventCount, bounceRate
|
|
152
|
+
|
|
153
|
+
Date formats: "today", "yesterday", "7daysAgo", "28daysAgo", "90daysAgo", or "YYYY-MM-DD".
|
|
154
|
+
If property_id is empty, uses the default from config.
|
|
155
|
+
"""
|
|
156
|
+
from adloop.ga4.reports import run_ga4_report as _impl
|
|
157
|
+
|
|
158
|
+
return _impl(
|
|
159
|
+
_config,
|
|
160
|
+
property_id=property_id or _config.ga4.property_id,
|
|
161
|
+
dimensions=dimensions,
|
|
162
|
+
metrics=metrics,
|
|
163
|
+
date_range_start=date_range_start,
|
|
164
|
+
date_range_end=date_range_end,
|
|
165
|
+
limit=limit,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@mcp.tool(annotations=_READONLY)
|
|
170
|
+
@_safe
|
|
171
|
+
def run_realtime_report(
|
|
172
|
+
dimensions: list[str] | None = None,
|
|
173
|
+
metrics: list[str] | None = None,
|
|
174
|
+
property_id: str = "",
|
|
175
|
+
) -> dict:
|
|
176
|
+
"""Run a GA4 realtime report showing current active users and events.
|
|
177
|
+
|
|
178
|
+
Useful for checking if tracking is firing correctly after code changes.
|
|
179
|
+
Common dimensions: unifiedScreenName, eventName, country, deviceCategory
|
|
180
|
+
Common metrics: activeUsers, eventCount
|
|
181
|
+
"""
|
|
182
|
+
from adloop.ga4.reports import run_realtime_report as _impl
|
|
183
|
+
|
|
184
|
+
return _impl(
|
|
185
|
+
_config,
|
|
186
|
+
property_id=property_id or _config.ga4.property_id,
|
|
187
|
+
dimensions=dimensions,
|
|
188
|
+
metrics=metrics,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@mcp.tool(annotations=_READONLY)
|
|
193
|
+
@_safe
|
|
194
|
+
def get_tracking_events(
|
|
195
|
+
date_range_start: str = "28daysAgo",
|
|
196
|
+
date_range_end: str = "today",
|
|
197
|
+
property_id: str = "",
|
|
198
|
+
) -> dict:
|
|
199
|
+
"""List all GA4 events and their volume for the given date range.
|
|
200
|
+
|
|
201
|
+
Returns every distinct event name with its total event count.
|
|
202
|
+
Use this to understand what tracking is configured and active.
|
|
203
|
+
"""
|
|
204
|
+
from adloop.ga4.tracking import get_tracking_events as _impl
|
|
205
|
+
|
|
206
|
+
return _impl(
|
|
207
|
+
_config,
|
|
208
|
+
property_id=property_id or _config.ga4.property_id,
|
|
209
|
+
date_range_start=date_range_start,
|
|
210
|
+
date_range_end=date_range_end,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# Google Ads Read Tools
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@mcp.tool(annotations=_READONLY)
|
|
220
|
+
@_safe
|
|
221
|
+
def list_accounts() -> dict:
|
|
222
|
+
"""List all accessible Google Ads accounts.
|
|
223
|
+
|
|
224
|
+
Returns account names, IDs, and status. Use this to discover
|
|
225
|
+
which accounts are available before running performance queries.
|
|
226
|
+
"""
|
|
227
|
+
from adloop.ads.read import list_accounts as _impl
|
|
228
|
+
|
|
229
|
+
return _impl(_config)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@mcp.tool(annotations=_READONLY)
|
|
233
|
+
@_safe
|
|
234
|
+
def get_campaign_performance(
|
|
235
|
+
customer_id: str = "",
|
|
236
|
+
date_range_start: str = "",
|
|
237
|
+
date_range_end: str = "",
|
|
238
|
+
) -> dict:
|
|
239
|
+
"""Get campaign-level performance metrics for a date range.
|
|
240
|
+
|
|
241
|
+
Returns: campaign name, status, type, impressions, clicks, cost,
|
|
242
|
+
conversions, CPA, ROAS, CTR for each campaign.
|
|
243
|
+
Date format: "YYYY-MM-DD". Empty = last 30 days.
|
|
244
|
+
"""
|
|
245
|
+
from adloop.ads.read import get_campaign_performance as _impl
|
|
246
|
+
|
|
247
|
+
return _impl(
|
|
248
|
+
_config,
|
|
249
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
250
|
+
date_range_start=date_range_start,
|
|
251
|
+
date_range_end=date_range_end,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@mcp.tool(annotations=_READONLY)
|
|
256
|
+
@_safe
|
|
257
|
+
def get_ad_performance(
|
|
258
|
+
customer_id: str = "",
|
|
259
|
+
date_range_start: str = "",
|
|
260
|
+
date_range_end: str = "",
|
|
261
|
+
) -> dict:
|
|
262
|
+
"""Get ad-level performance data including headlines, descriptions, and metrics.
|
|
263
|
+
|
|
264
|
+
Returns: ad type, headlines, descriptions, final URL, impressions,
|
|
265
|
+
clicks, CTR, conversions, cost for each ad.
|
|
266
|
+
"""
|
|
267
|
+
from adloop.ads.read import get_ad_performance as _impl
|
|
268
|
+
|
|
269
|
+
return _impl(
|
|
270
|
+
_config,
|
|
271
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
272
|
+
date_range_start=date_range_start,
|
|
273
|
+
date_range_end=date_range_end,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@mcp.tool(annotations=_READONLY)
|
|
278
|
+
@_safe
|
|
279
|
+
def get_keyword_performance(
|
|
280
|
+
customer_id: str = "",
|
|
281
|
+
date_range_start: str = "",
|
|
282
|
+
date_range_end: str = "",
|
|
283
|
+
) -> dict:
|
|
284
|
+
"""Get keyword metrics including quality scores and competitive data.
|
|
285
|
+
|
|
286
|
+
Returns: keyword text, match type, quality score, impressions,
|
|
287
|
+
clicks, CTR, CPC, conversions for each keyword.
|
|
288
|
+
"""
|
|
289
|
+
from adloop.ads.read import get_keyword_performance as _impl
|
|
290
|
+
|
|
291
|
+
return _impl(
|
|
292
|
+
_config,
|
|
293
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
294
|
+
date_range_start=date_range_start,
|
|
295
|
+
date_range_end=date_range_end,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@mcp.tool(annotations=_READONLY)
|
|
300
|
+
@_safe
|
|
301
|
+
def get_search_terms(
|
|
302
|
+
customer_id: str = "",
|
|
303
|
+
date_range_start: str = "",
|
|
304
|
+
date_range_end: str = "",
|
|
305
|
+
) -> dict:
|
|
306
|
+
"""Get search terms report — what users actually typed before clicking your ads.
|
|
307
|
+
|
|
308
|
+
Critical for finding negative keyword opportunities and understanding user intent.
|
|
309
|
+
Returns: search term, campaign, ad group, impressions, clicks, conversions.
|
|
310
|
+
"""
|
|
311
|
+
from adloop.ads.read import get_search_terms as _impl
|
|
312
|
+
|
|
313
|
+
return _impl(
|
|
314
|
+
_config,
|
|
315
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
316
|
+
date_range_start=date_range_start,
|
|
317
|
+
date_range_end=date_range_end,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@mcp.tool(annotations=_READONLY)
|
|
322
|
+
@_safe
|
|
323
|
+
def get_negative_keywords(
|
|
324
|
+
customer_id: str = "",
|
|
325
|
+
campaign_id: str = "",
|
|
326
|
+
) -> dict:
|
|
327
|
+
"""List existing negative keywords for a campaign or all campaigns.
|
|
328
|
+
|
|
329
|
+
Use this before adding negative keywords to check for duplicates.
|
|
330
|
+
If campaign_id is empty, returns negatives across all campaigns.
|
|
331
|
+
"""
|
|
332
|
+
from adloop.ads.read import get_negative_keywords as _impl
|
|
333
|
+
|
|
334
|
+
return _impl(
|
|
335
|
+
_config,
|
|
336
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
337
|
+
campaign_id=campaign_id,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@mcp.tool(annotations=_READONLY)
|
|
342
|
+
@_safe
|
|
343
|
+
def analyze_campaign_conversions(
|
|
344
|
+
date_range_start: str = "",
|
|
345
|
+
date_range_end: str = "",
|
|
346
|
+
customer_id: str = "",
|
|
347
|
+
property_id: str = "",
|
|
348
|
+
campaign_name: str = "",
|
|
349
|
+
) -> dict:
|
|
350
|
+
"""Campaign clicks → GA4 conversions mapping — the real cost-per-conversion.
|
|
351
|
+
|
|
352
|
+
Combines Google Ads campaign metrics with GA4 session/conversion data to
|
|
353
|
+
reveal click-to-session ratios (GDPR indicator), compare Ads-reported vs
|
|
354
|
+
GA4-reported conversions, and compute cost-per-GA4-conversion.
|
|
355
|
+
|
|
356
|
+
Also returns non-paid channel conversion rates for comparison context.
|
|
357
|
+
Date format: "YYYY-MM-DD". Empty = last 30 days.
|
|
358
|
+
"""
|
|
359
|
+
from adloop.crossref import analyze_campaign_conversions as _impl
|
|
360
|
+
|
|
361
|
+
return _impl(
|
|
362
|
+
_config,
|
|
363
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
364
|
+
property_id=property_id or _config.ga4.property_id,
|
|
365
|
+
date_range_start=date_range_start,
|
|
366
|
+
date_range_end=date_range_end,
|
|
367
|
+
campaign_name=campaign_name,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@mcp.tool(annotations=_READONLY)
|
|
372
|
+
@_safe
|
|
373
|
+
def landing_page_analysis(
|
|
374
|
+
date_range_start: str = "",
|
|
375
|
+
date_range_end: str = "",
|
|
376
|
+
customer_id: str = "",
|
|
377
|
+
property_id: str = "",
|
|
378
|
+
) -> dict:
|
|
379
|
+
"""Analyze which landing pages convert and which don't.
|
|
380
|
+
|
|
381
|
+
Combines ad final URLs with GA4 page-level data to show paid traffic
|
|
382
|
+
sessions, conversion rates, bounce rates, and engagement per landing page.
|
|
383
|
+
Identifies pages that get ad clicks but zero conversions and orphaned URLs.
|
|
384
|
+
Date format: "YYYY-MM-DD". Empty = last 30 days.
|
|
385
|
+
"""
|
|
386
|
+
from adloop.crossref import landing_page_analysis as _impl
|
|
387
|
+
|
|
388
|
+
return _impl(
|
|
389
|
+
_config,
|
|
390
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
391
|
+
property_id=property_id or _config.ga4.property_id,
|
|
392
|
+
date_range_start=date_range_start,
|
|
393
|
+
date_range_end=date_range_end,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@mcp.tool(annotations=_READONLY)
|
|
398
|
+
@_safe
|
|
399
|
+
def attribution_check(
|
|
400
|
+
date_range_start: str = "",
|
|
401
|
+
date_range_end: str = "",
|
|
402
|
+
customer_id: str = "",
|
|
403
|
+
property_id: str = "",
|
|
404
|
+
conversion_events: list[str] | None = None,
|
|
405
|
+
) -> dict:
|
|
406
|
+
"""Compare Ads-reported conversions vs GA4 — find tracking discrepancies.
|
|
407
|
+
|
|
408
|
+
Checks whether conversions reported by Google Ads match what GA4 records,
|
|
409
|
+
diagnoses GDPR consent gaps, attribution model differences, and missing
|
|
410
|
+
conversion event configuration.
|
|
411
|
+
|
|
412
|
+
conversion_events: optional list of GA4 event names to specifically check
|
|
413
|
+
(e.g. ["sign_up", "purchase"]). If omitted, compares aggregate totals only.
|
|
414
|
+
Date format: "YYYY-MM-DD". Empty = last 30 days.
|
|
415
|
+
"""
|
|
416
|
+
from adloop.crossref import attribution_check as _impl
|
|
417
|
+
|
|
418
|
+
return _impl(
|
|
419
|
+
_config,
|
|
420
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
421
|
+
property_id=property_id or _config.ga4.property_id,
|
|
422
|
+
date_range_start=date_range_start,
|
|
423
|
+
date_range_end=date_range_end,
|
|
424
|
+
conversion_events=conversion_events,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@mcp.tool(annotations=_READONLY)
|
|
429
|
+
@_safe
|
|
430
|
+
def run_gaql(
|
|
431
|
+
query: str,
|
|
432
|
+
customer_id: str = "",
|
|
433
|
+
format: str = "table",
|
|
434
|
+
) -> dict:
|
|
435
|
+
"""Execute an arbitrary GAQL (Google Ads Query Language) query.
|
|
436
|
+
|
|
437
|
+
Use this for advanced queries not covered by the other tools.
|
|
438
|
+
See the GAQL reference in the AdLoop cursor rules for syntax help.
|
|
439
|
+
|
|
440
|
+
format: "table" (default, readable), "json" (structured), "csv" (exportable)
|
|
441
|
+
"""
|
|
442
|
+
from adloop.ads.gaql import run_gaql as _impl
|
|
443
|
+
|
|
444
|
+
return _impl(
|
|
445
|
+
_config,
|
|
446
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
447
|
+
query=query,
|
|
448
|
+
format=format,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
# Google Ads Write Tools (Safety Layer)
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@mcp.tool(annotations=_WRITE)
|
|
458
|
+
@_safe
|
|
459
|
+
def draft_campaign(
|
|
460
|
+
campaign_name: str,
|
|
461
|
+
daily_budget: float,
|
|
462
|
+
bidding_strategy: str,
|
|
463
|
+
customer_id: str = "",
|
|
464
|
+
target_cpa: float = 0,
|
|
465
|
+
target_roas: float = 0,
|
|
466
|
+
channel_type: str = "SEARCH",
|
|
467
|
+
ad_group_name: str = "",
|
|
468
|
+
keywords: list[dict] | None = None,
|
|
469
|
+
) -> dict:
|
|
470
|
+
"""Draft a full campaign structure — returns a PREVIEW, does NOT create anything.
|
|
471
|
+
|
|
472
|
+
Creates: CampaignBudget + Campaign (PAUSED) + AdGroup + optional Keywords.
|
|
473
|
+
Ads are NOT included — use draft_responsive_search_ad after the campaign exists.
|
|
474
|
+
|
|
475
|
+
bidding_strategy: MAXIMIZE_CONVERSIONS | TARGET_CPA | TARGET_ROAS |
|
|
476
|
+
MAXIMIZE_CONVERSION_VALUE | TARGET_SPEND | MANUAL_CPC
|
|
477
|
+
target_cpa: required if bidding_strategy is TARGET_CPA (in account currency)
|
|
478
|
+
target_roas: required if bidding_strategy is TARGET_ROAS
|
|
479
|
+
keywords: list of {"text": "keyword", "match_type": "EXACT|PHRASE|BROAD"}
|
|
480
|
+
|
|
481
|
+
Call confirm_and_apply with the returned plan_id to execute.
|
|
482
|
+
"""
|
|
483
|
+
from adloop.ads.write import draft_campaign as _impl
|
|
484
|
+
|
|
485
|
+
return _impl(
|
|
486
|
+
_config,
|
|
487
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
488
|
+
campaign_name=campaign_name,
|
|
489
|
+
daily_budget=daily_budget,
|
|
490
|
+
bidding_strategy=bidding_strategy,
|
|
491
|
+
target_cpa=target_cpa,
|
|
492
|
+
target_roas=target_roas,
|
|
493
|
+
channel_type=channel_type,
|
|
494
|
+
ad_group_name=ad_group_name,
|
|
495
|
+
keywords=keywords,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@mcp.tool(annotations=_WRITE)
|
|
500
|
+
@_safe
|
|
501
|
+
def draft_responsive_search_ad(
|
|
502
|
+
ad_group_id: str,
|
|
503
|
+
headlines: list[str],
|
|
504
|
+
descriptions: list[str],
|
|
505
|
+
final_url: str,
|
|
506
|
+
customer_id: str = "",
|
|
507
|
+
path1: str = "",
|
|
508
|
+
path2: str = "",
|
|
509
|
+
) -> dict:
|
|
510
|
+
"""Draft a Responsive Search Ad — returns a PREVIEW, does NOT create the ad.
|
|
511
|
+
|
|
512
|
+
Provide 3-15 headlines (max 30 chars each) and 2-4 descriptions (max 90 chars each).
|
|
513
|
+
The preview shows exactly what will be created. Call confirm_and_apply to execute.
|
|
514
|
+
"""
|
|
515
|
+
from adloop.ads.write import draft_responsive_search_ad as _impl
|
|
516
|
+
|
|
517
|
+
return _impl(
|
|
518
|
+
_config,
|
|
519
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
520
|
+
ad_group_id=ad_group_id,
|
|
521
|
+
headlines=headlines,
|
|
522
|
+
descriptions=descriptions,
|
|
523
|
+
final_url=final_url,
|
|
524
|
+
path1=path1,
|
|
525
|
+
path2=path2,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
@mcp.tool(annotations=_WRITE)
|
|
530
|
+
@_safe
|
|
531
|
+
def draft_keywords(
|
|
532
|
+
ad_group_id: str,
|
|
533
|
+
keywords: list[dict],
|
|
534
|
+
customer_id: str = "",
|
|
535
|
+
) -> dict:
|
|
536
|
+
"""Draft keyword additions — returns a PREVIEW, does NOT add keywords.
|
|
537
|
+
|
|
538
|
+
keywords: list of {"text": "keyword phrase", "match_type": "EXACT|PHRASE|BROAD"}
|
|
539
|
+
Call confirm_and_apply with the returned plan_id to execute.
|
|
540
|
+
"""
|
|
541
|
+
from adloop.ads.write import draft_keywords as _impl
|
|
542
|
+
|
|
543
|
+
return _impl(
|
|
544
|
+
_config,
|
|
545
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
546
|
+
ad_group_id=ad_group_id,
|
|
547
|
+
keywords=keywords,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@mcp.tool(annotations=_WRITE)
|
|
552
|
+
@_safe
|
|
553
|
+
def add_negative_keywords(
|
|
554
|
+
campaign_id: str,
|
|
555
|
+
keywords: list[str],
|
|
556
|
+
customer_id: str = "",
|
|
557
|
+
match_type: str = "EXACT",
|
|
558
|
+
) -> dict:
|
|
559
|
+
"""Draft negative keyword additions — returns a PREVIEW.
|
|
560
|
+
|
|
561
|
+
Negative keywords prevent your ads from showing for irrelevant searches.
|
|
562
|
+
match_type: "EXACT", "PHRASE", or "BROAD"
|
|
563
|
+
Call confirm_and_apply with the returned plan_id to execute.
|
|
564
|
+
"""
|
|
565
|
+
from adloop.ads.write import add_negative_keywords as _impl
|
|
566
|
+
|
|
567
|
+
return _impl(
|
|
568
|
+
_config,
|
|
569
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
570
|
+
campaign_id=campaign_id,
|
|
571
|
+
keywords=keywords,
|
|
572
|
+
match_type=match_type,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@mcp.tool(annotations=_WRITE)
|
|
577
|
+
@_safe
|
|
578
|
+
def pause_entity(
|
|
579
|
+
entity_type: str,
|
|
580
|
+
entity_id: str,
|
|
581
|
+
customer_id: str = "",
|
|
582
|
+
) -> dict:
|
|
583
|
+
"""Draft pausing a campaign, ad group, ad, or keyword — returns a PREVIEW.
|
|
584
|
+
|
|
585
|
+
entity_type: "campaign", "ad_group", "ad", or "keyword"
|
|
586
|
+
entity_id format by type:
|
|
587
|
+
- campaign: campaign ID (e.g. "12345678")
|
|
588
|
+
- ad_group: ad group ID (e.g. "12345678")
|
|
589
|
+
- ad: "adGroupId~adId" (e.g. "12345678~987654")
|
|
590
|
+
- keyword: "adGroupId~criterionId" (e.g. "12345678~987654")
|
|
591
|
+
|
|
592
|
+
Call confirm_and_apply with the returned plan_id to execute.
|
|
593
|
+
"""
|
|
594
|
+
from adloop.ads.write import pause_entity as _impl
|
|
595
|
+
|
|
596
|
+
return _impl(
|
|
597
|
+
_config,
|
|
598
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
599
|
+
entity_type=entity_type,
|
|
600
|
+
entity_id=entity_id,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@mcp.tool(annotations=_WRITE)
|
|
605
|
+
@_safe
|
|
606
|
+
def enable_entity(
|
|
607
|
+
entity_type: str,
|
|
608
|
+
entity_id: str,
|
|
609
|
+
customer_id: str = "",
|
|
610
|
+
) -> dict:
|
|
611
|
+
"""Draft enabling a paused campaign, ad group, ad, or keyword — returns a PREVIEW.
|
|
612
|
+
|
|
613
|
+
entity_type: "campaign", "ad_group", "ad", or "keyword"
|
|
614
|
+
entity_id format by type:
|
|
615
|
+
- campaign: campaign ID (e.g. "12345678")
|
|
616
|
+
- ad_group: ad group ID (e.g. "12345678")
|
|
617
|
+
- ad: "adGroupId~adId" (e.g. "12345678~987654")
|
|
618
|
+
- keyword: "adGroupId~criterionId" (e.g. "12345678~987654")
|
|
619
|
+
|
|
620
|
+
Call confirm_and_apply with the returned plan_id to execute.
|
|
621
|
+
"""
|
|
622
|
+
from adloop.ads.write import enable_entity as _impl
|
|
623
|
+
|
|
624
|
+
return _impl(
|
|
625
|
+
_config,
|
|
626
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
627
|
+
entity_type=entity_type,
|
|
628
|
+
entity_id=entity_id,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@mcp.tool(annotations=_DESTRUCTIVE)
|
|
633
|
+
@_safe
|
|
634
|
+
def remove_entity(
|
|
635
|
+
entity_type: str,
|
|
636
|
+
entity_id: str,
|
|
637
|
+
customer_id: str = "",
|
|
638
|
+
) -> dict:
|
|
639
|
+
"""Draft REMOVING an entity — returns a PREVIEW. This is IRREVERSIBLE.
|
|
640
|
+
|
|
641
|
+
entity_type: "campaign", "ad_group", "ad", "keyword", or "negative_keyword"
|
|
642
|
+
entity_id: The resource ID. For keywords use "adGroupId~criterionId".
|
|
643
|
+
For negative_keywords use the campaign criterion ID.
|
|
644
|
+
|
|
645
|
+
WARNING: Removed entities cannot be re-enabled. Use pause_entity instead
|
|
646
|
+
if you just want to temporarily disable something.
|
|
647
|
+
|
|
648
|
+
Call confirm_and_apply with the returned plan_id to execute.
|
|
649
|
+
"""
|
|
650
|
+
from adloop.ads.write import remove_entity as _impl
|
|
651
|
+
|
|
652
|
+
return _impl(
|
|
653
|
+
_config,
|
|
654
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
655
|
+
entity_type=entity_type,
|
|
656
|
+
entity_id=entity_id,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
@mcp.tool(annotations=_DESTRUCTIVE)
|
|
661
|
+
@_safe
|
|
662
|
+
def confirm_and_apply(
|
|
663
|
+
plan_id: str,
|
|
664
|
+
dry_run: bool = True,
|
|
665
|
+
) -> dict:
|
|
666
|
+
"""Execute a previously previewed change.
|
|
667
|
+
|
|
668
|
+
IMPORTANT: Defaults to dry_run=True. You MUST explicitly pass dry_run=false
|
|
669
|
+
to make real changes to the Google Ads account.
|
|
670
|
+
|
|
671
|
+
The plan_id comes from a prior draft_* or pause/enable tool call.
|
|
672
|
+
"""
|
|
673
|
+
from adloop.ads.write import confirm_and_apply as _impl
|
|
674
|
+
|
|
675
|
+
return _impl(_config, plan_id=plan_id, dry_run=dry_run)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# ---------------------------------------------------------------------------
|
|
679
|
+
# Tracking Tools
|
|
680
|
+
# ---------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@mcp.tool(annotations=_READONLY)
|
|
684
|
+
@_safe
|
|
685
|
+
def validate_tracking(
|
|
686
|
+
expected_events: list[str],
|
|
687
|
+
property_id: str = "",
|
|
688
|
+
date_range_start: str = "28daysAgo",
|
|
689
|
+
date_range_end: str = "today",
|
|
690
|
+
) -> dict:
|
|
691
|
+
"""Compare tracking events found in the codebase against actual GA4 data.
|
|
692
|
+
|
|
693
|
+
First, search the user's codebase for gtag('event', ...) or dataLayer.push
|
|
694
|
+
calls and extract event names. Then pass those names here to check which
|
|
695
|
+
ones actually fire in GA4.
|
|
696
|
+
|
|
697
|
+
Returns: matched events, events missing from GA4, unexpected GA4 events,
|
|
698
|
+
and auto-collected events (page_view, session_start, etc.).
|
|
699
|
+
"""
|
|
700
|
+
from adloop.tracking import validate_tracking as _impl
|
|
701
|
+
|
|
702
|
+
return _impl(
|
|
703
|
+
_config,
|
|
704
|
+
expected_events=expected_events,
|
|
705
|
+
property_id=property_id or _config.ga4.property_id,
|
|
706
|
+
date_range_start=date_range_start,
|
|
707
|
+
date_range_end=date_range_end,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@mcp.tool(annotations=_READONLY)
|
|
712
|
+
@_safe
|
|
713
|
+
def generate_tracking_code(
|
|
714
|
+
event_name: str,
|
|
715
|
+
event_params: dict | None = None,
|
|
716
|
+
trigger: str = "",
|
|
717
|
+
property_id: str = "",
|
|
718
|
+
check_existing: bool = True,
|
|
719
|
+
) -> dict:
|
|
720
|
+
"""Generate a GA4 event tracking JavaScript snippet.
|
|
721
|
+
|
|
722
|
+
Produces ready-to-paste gtag code for the specified event. Includes
|
|
723
|
+
recommended parameters for well-known GA4 events (sign_up, purchase, etc.).
|
|
724
|
+
Optionally checks GA4 to warn if the event already fires.
|
|
725
|
+
|
|
726
|
+
trigger: "form_submit", "button_click", or "page_load" — wraps the gtag
|
|
727
|
+
call in an appropriate event listener. Empty = bare gtag call.
|
|
728
|
+
"""
|
|
729
|
+
from adloop.tracking import generate_tracking_code as _impl
|
|
730
|
+
|
|
731
|
+
return _impl(
|
|
732
|
+
_config,
|
|
733
|
+
event_name=event_name,
|
|
734
|
+
event_params=event_params,
|
|
735
|
+
trigger=trigger,
|
|
736
|
+
property_id=property_id or _config.ga4.property_id,
|
|
737
|
+
check_existing=check_existing,
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# ---------------------------------------------------------------------------
|
|
742
|
+
# Planning Tools
|
|
743
|
+
# ---------------------------------------------------------------------------
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
@mcp.tool(annotations=_READONLY)
|
|
747
|
+
@_safe
|
|
748
|
+
def estimate_budget(
|
|
749
|
+
keywords: list[dict],
|
|
750
|
+
daily_budget: float = 0,
|
|
751
|
+
geo_target_id: str = "2276",
|
|
752
|
+
language_id: str = "1000",
|
|
753
|
+
forecast_days: int = 30,
|
|
754
|
+
customer_id: str = "",
|
|
755
|
+
) -> dict:
|
|
756
|
+
"""Forecast clicks, impressions, and cost for a set of keywords.
|
|
757
|
+
|
|
758
|
+
Uses Google Ads Keyword Planner to estimate campaign performance without
|
|
759
|
+
creating anything. Essential for budget planning before launching campaigns.
|
|
760
|
+
|
|
761
|
+
keywords: list of {"text": "keyword", "match_type": "EXACT|PHRASE|BROAD", "max_cpc": 1.50}
|
|
762
|
+
max_cpc is optional (defaults to 1.00 in account currency)
|
|
763
|
+
geo_target_id: geo target constant (2276=Germany, 2840=USA, 2826=UK, 2250=France)
|
|
764
|
+
language_id: language constant (1000=English, 1001=German, 1002=French, 1003=Spanish)
|
|
765
|
+
daily_budget: if provided, insights will show what % of traffic the budget captures
|
|
766
|
+
forecast_days: forecast horizon in days (default 30)
|
|
767
|
+
"""
|
|
768
|
+
from adloop.ads.forecast import estimate_budget as _impl
|
|
769
|
+
|
|
770
|
+
return _impl(
|
|
771
|
+
_config,
|
|
772
|
+
keywords=keywords,
|
|
773
|
+
daily_budget=daily_budget,
|
|
774
|
+
geo_target_id=geo_target_id,
|
|
775
|
+
language_id=language_id,
|
|
776
|
+
forecast_days=forecast_days,
|
|
777
|
+
customer_id=customer_id or _config.ads.customer_id,
|
|
778
|
+
)
|