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/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
+ )