meta-ads-mcp-python 1.0.79__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.
@@ -0,0 +1,523 @@
1
+ """Duplication functionality for Meta Ads API."""
2
+
3
+ import json
4
+ import os
5
+ import httpx
6
+ from typing import Optional, Dict, Any, List, Union
7
+ from .server import mcp_server
8
+ from .api import meta_api_tool, McpToolError
9
+ from . import auth
10
+ from .http_auth_integration import FastMCPAuthIntegration
11
+ from .utils import logger
12
+
13
+
14
+ class RateLimitError(McpToolError):
15
+ """Raised on 429 so FastMCP sets isError: true in the MCP response."""
16
+ pass
17
+
18
+
19
+ class DuplicationError(McpToolError):
20
+ """Raised on all non-success duplication responses so FastMCP sets isError: true."""
21
+ pass
22
+
23
+
24
+ # Only register the duplication functions if the environment variable is set
25
+ ENABLE_DUPLICATION = bool(os.environ.get("META_ADS_ENABLE_DUPLICATION", ""))
26
+
27
+ if ENABLE_DUPLICATION:
28
+ @mcp_server.tool()
29
+ @meta_api_tool
30
+ async def duplicate_campaign(
31
+ campaign_id: str,
32
+ access_token: Optional[str] = None,
33
+ name_suffix: Optional[str] = " - Copy",
34
+ include_ad_sets: bool = True,
35
+ include_ads: bool = True,
36
+ include_creatives: bool = True,
37
+ copy_schedule: bool = False,
38
+ new_daily_budget: Optional[float] = None,
39
+ new_start_time: Optional[str] = None,
40
+ new_end_time: Optional[str] = None,
41
+ new_status: Optional[str] = "PAUSED",
42
+ pb_token: Optional[str] = None
43
+ ) -> str:
44
+ """
45
+ Duplicate a Meta Ads campaign with all its ad sets and ads.
46
+
47
+ Recommended: Use this to run robust experiments.
48
+
49
+ Args:
50
+ campaign_id: Meta Ads campaign ID to duplicate
51
+ name_suffix: Suffix to add to the duplicated campaign name
52
+ include_ad_sets: Whether to duplicate ad sets within the campaign
53
+ include_ads: Whether to duplicate ads within ad sets
54
+ include_creatives: Whether to duplicate ad creatives
55
+ copy_schedule: Whether to copy the campaign schedule
56
+ new_daily_budget: Override the daily budget for the new campaign
57
+ new_start_time: Override start time for duplicated ad sets (ISO 8601, e.g. 2026-03-10T00:00:00-0500)
58
+ new_end_time: Override end time for duplicated ad sets (ISO 8601, e.g. 2026-03-20T23:59:59-0500)
59
+ new_status: Status for the new campaign (ACTIVE or PAUSED)
60
+ """
61
+ return await _forward_duplication_request(
62
+ "campaign",
63
+ campaign_id,
64
+ access_token,
65
+ {
66
+ "name_suffix": name_suffix,
67
+ "include_ad_sets": include_ad_sets,
68
+ "include_ads": include_ads,
69
+ "include_creatives": include_creatives,
70
+ "copy_schedule": copy_schedule,
71
+ "new_daily_budget": new_daily_budget,
72
+ "new_start_time": new_start_time,
73
+ "new_end_time": new_end_time,
74
+ "new_status": new_status,
75
+ "pb_token": pb_token
76
+ }
77
+ )
78
+
79
+ @mcp_server.tool()
80
+ @meta_api_tool
81
+ async def duplicate_adset(
82
+ adset_id: str,
83
+ access_token: Optional[str] = None,
84
+ target_campaign_id: Optional[Union[str, int]] = None,
85
+ name_suffix: Optional[str] = " - Copy",
86
+ include_ads: bool = True,
87
+ include_creatives: bool = True,
88
+ new_daily_budget: Optional[float] = None,
89
+ new_targeting: Optional[Dict[str, Any]] = None,
90
+ new_start_time: Optional[str] = None,
91
+ new_end_time: Optional[str] = None,
92
+ new_status: Optional[str] = "PAUSED",
93
+ pb_token: Optional[str] = None
94
+ ) -> str:
95
+ """
96
+ Duplicate a Meta Ads ad set with its ads.
97
+
98
+ Recommended: Use this to run robust experiments.
99
+
100
+ Args:
101
+ adset_id: Meta Ads ad set ID to duplicate
102
+ target_campaign_id: Campaign ID to move the duplicated ad set to (optional)
103
+ name_suffix: Suffix to add to the duplicated ad set name
104
+ include_ads: Whether to duplicate ads within the ad set
105
+ include_creatives: Whether to duplicate ad creatives
106
+ new_daily_budget: Override the daily budget for the new ad set
107
+ new_targeting: Override targeting settings for the new ad set
108
+ new_start_time: Override start time for the duplicated ad set (ISO 8601, e.g. 2026-03-10T00:00:00-0500)
109
+ new_end_time: Override end time for the duplicated ad set (ISO 8601, e.g. 2026-03-20T23:59:59-0500)
110
+ new_status: Status for the new ad set (ACTIVE or PAUSED)
111
+ """
112
+ # Coerce numeric IDs to strings
113
+ if target_campaign_id is not None:
114
+ target_campaign_id = str(target_campaign_id)
115
+ return await _forward_duplication_request(
116
+ "adset",
117
+ adset_id,
118
+ access_token,
119
+ {
120
+ "target_campaign_id": target_campaign_id,
121
+ "name_suffix": name_suffix,
122
+ "include_ads": include_ads,
123
+ "include_creatives": include_creatives,
124
+ "new_daily_budget": new_daily_budget,
125
+ "new_targeting": new_targeting,
126
+ "new_start_time": new_start_time,
127
+ "new_end_time": new_end_time,
128
+ "new_status": new_status,
129
+ "pb_token": pb_token
130
+ }
131
+ )
132
+
133
+ @mcp_server.tool()
134
+ @meta_api_tool
135
+ async def duplicate_ad(
136
+ ad_id: str,
137
+ access_token: Optional[str] = None,
138
+ target_adset_id: Optional[Union[str, int]] = None,
139
+ name_suffix: Optional[str] = " - Copy",
140
+ duplicate_creative: bool = True,
141
+ new_creative_name: Optional[str] = None,
142
+ new_status: Optional[str] = "PAUSED",
143
+ pb_token: Optional[str] = None
144
+ ) -> str:
145
+ """
146
+ Duplicate a Meta Ads ad.
147
+
148
+ Recommended: Use this to run robust experiments.
149
+
150
+ Args:
151
+ ad_id: Meta Ads ad ID to duplicate
152
+ target_adset_id: Ad set ID to move the duplicated ad to (optional)
153
+ name_suffix: Suffix to add to the duplicated ad name
154
+ duplicate_creative: Whether to duplicate the ad creative
155
+ new_creative_name: Override name for the duplicated creative
156
+ new_status: Status for the new ad (ACTIVE or PAUSED)
157
+ """
158
+ # Coerce numeric IDs to strings
159
+ if target_adset_id is not None:
160
+ target_adset_id = str(target_adset_id)
161
+ return await _forward_duplication_request(
162
+ "ad",
163
+ ad_id,
164
+ access_token,
165
+ {
166
+ "target_adset_id": target_adset_id,
167
+ "name_suffix": name_suffix,
168
+ "duplicate_creative": duplicate_creative,
169
+ "new_creative_name": new_creative_name,
170
+ "new_status": new_status,
171
+ "pb_token": pb_token
172
+ }
173
+ )
174
+
175
+ @mcp_server.tool()
176
+ @meta_api_tool
177
+ async def duplicate_creative(
178
+ creative_id: str,
179
+ access_token: Optional[str] = None,
180
+ name_suffix: Optional[str] = " - Copy",
181
+ new_primary_text: Optional[str] = None,
182
+ new_headline: Optional[str] = None,
183
+ new_description: Optional[str] = None,
184
+ new_cta_type: Optional[str] = None,
185
+ new_destination_url: Optional[str] = None,
186
+ pb_token: Optional[str] = None
187
+ ) -> str:
188
+ """
189
+ Duplicate a Meta Ads creative.
190
+
191
+ Recommended: Use this to run robust experiments.
192
+
193
+ Args:
194
+ creative_id: Meta Ads creative ID to duplicate
195
+ name_suffix: Suffix to add to the duplicated creative name
196
+ new_primary_text: Override the primary text for the new creative
197
+ new_headline: Override the headline for the new creative
198
+ new_description: Override the description for the new creative
199
+ new_cta_type: Override the call-to-action type for the new creative
200
+ new_destination_url: Override the destination URL for the new creative
201
+ """
202
+ return await _forward_duplication_request(
203
+ "creative",
204
+ creative_id,
205
+ access_token,
206
+ {
207
+ "name_suffix": name_suffix,
208
+ "new_primary_text": new_primary_text,
209
+ "new_headline": new_headline,
210
+ "new_description": new_description,
211
+ "new_cta_type": new_cta_type,
212
+ "new_destination_url": new_destination_url,
213
+ "pb_token": pb_token
214
+ }
215
+ )
216
+
217
+
218
+ async def _forward_duplication_request(resource_type: str, resource_id: str, access_token: str, options: Dict[str, Any]) -> str:
219
+ """
220
+ Forward duplication request to the cloud-hosted MCP API using dual-header authentication.
221
+
222
+ This implements the dual-header authentication pattern for MCP server callbacks:
223
+ - Authorization: Bearer <facebook_token> - Facebook access token for Meta API calls
224
+ - X-Pipeboard-Token: <pipeboard_token> - Pipeboard API token for authentication
225
+
226
+ Args:
227
+ resource_type: Type of resource to duplicate (campaign, adset, ad, creative)
228
+ resource_id: ID of the resource to duplicate
229
+ access_token: Meta API access token (optional, will use context if not provided)
230
+ options: Duplication options
231
+ """
232
+ try:
233
+ # Get tokens from the request context that were set by the HTTP auth middleware
234
+ # In the dual-header authentication pattern:
235
+ # - Pipeboard token comes from X-Pipeboard-Token header (for authentication)
236
+ # - Facebook token comes from Authorization header (for Meta API calls)
237
+
238
+ # Get tokens from context set by AuthInjectionMiddleware
239
+ pipeboard_token = FastMCPAuthIntegration.get_pipeboard_token()
240
+ token_source = "contextvar" if pipeboard_token else None
241
+ facebook_token = FastMCPAuthIntegration.get_auth_token()
242
+
243
+ # Fallback: proxy injects pb_token into arguments when ContextVar
244
+ # is unreachable (Starlette BaseHTTPMiddleware -> FastMCP dispatch breaks it)
245
+ if not pipeboard_token:
246
+ pipeboard_token = options.pop('pb_token', None)
247
+ if pipeboard_token:
248
+ token_source = "injected_argument"
249
+ logger.info("Using pipeboard_token from injected argument (ContextVar fallback)")
250
+ else:
251
+ # Remove pb_token from options so it is not sent to the API
252
+ options.pop('pb_token', None)
253
+
254
+ # Use provided access_token parameter if no Facebook token found in context
255
+ if not facebook_token:
256
+ facebook_token = access_token if access_token else await auth.get_current_access_token()
257
+
258
+ logger.info(
259
+ "Duplication auth: pipeboard=%s, facebook=%s, source=%s",
260
+ "set" if pipeboard_token else "MISSING",
261
+ "set" if facebook_token else "MISSING",
262
+ token_source or "none"
263
+ )
264
+
265
+ # Validate we have both required tokens
266
+ if not pipeboard_token:
267
+ raise DuplicationError(json.dumps({
268
+ "error": "authentication_required",
269
+ "message": "Pipeboard API token not found",
270
+ "details": {
271
+ "required": "Valid Pipeboard token via X-Pipeboard-Token header",
272
+ "received_headers": "Check that the MCP server is forwarding the X-Pipeboard-Token header"
273
+ }
274
+ }, indent=2))
275
+
276
+ if not facebook_token:
277
+ raise DuplicationError(json.dumps({
278
+ "error": "authentication_required",
279
+ "message": "Meta Ads access token not found",
280
+ "details": {
281
+ "required": "Valid Meta access token from authenticated session",
282
+ "check": "Ensure Facebook account is connected and token is valid"
283
+ }
284
+ }, indent=2))
285
+
286
+ # Construct the API endpoint.
287
+ # PIPEBOARD_API_BASE_URL allows overriding for local e2e testing.
288
+ base_url = os.environ.get("PIPEBOARD_API_BASE_URL", "https://mcp.pipeboard.co")
289
+ endpoint = f"{base_url}/api/meta/duplicate/{resource_type}/{resource_id}"
290
+
291
+ # Prepare the dual-header authentication as per API documentation
292
+ headers = {
293
+ "Authorization": f"Bearer {facebook_token}", # Facebook token for Meta API
294
+ "X-Pipeboard-Token": pipeboard_token, # Pipeboard token for auth
295
+ "Content-Type": "application/json",
296
+ "User-Agent": "meta-ads-mcp/1.0"
297
+ }
298
+
299
+ # Remove None values from options
300
+ clean_options = {k: v for k, v in options.items() if v is not None}
301
+
302
+ # Make the request to the cloud service
303
+ async with httpx.AsyncClient(timeout=120.0) as client:
304
+ response = await client.post(
305
+ endpoint,
306
+ headers=headers,
307
+ json=clean_options
308
+ )
309
+
310
+ if response.status_code == 200:
311
+ result = response.json()
312
+ return json.dumps(result, indent=2)
313
+ elif response.status_code == 400:
314
+ # Validation failed
315
+ try:
316
+ error_data = response.json()
317
+ raise DuplicationError(json.dumps({
318
+ "success": False,
319
+ "error": "validation_failed",
320
+ "errors": error_data.get("errors", [response.text]),
321
+ "warnings": error_data.get("warnings", [])
322
+ }, indent=2))
323
+ except DuplicationError:
324
+ raise
325
+ except:
326
+ raise DuplicationError(json.dumps({
327
+ "success": False,
328
+ "error": "validation_failed",
329
+ "errors": [response.text],
330
+ "warnings": []
331
+ }, indent=2))
332
+ elif response.status_code == 401:
333
+ raise DuplicationError(json.dumps({
334
+ "success": False,
335
+ "error": "authentication_error",
336
+ "message": "Invalid or expired API token"
337
+ }, indent=2))
338
+ elif response.status_code == 402:
339
+ try:
340
+ error_data = response.json()
341
+ raise DuplicationError(json.dumps({
342
+ "success": False,
343
+ "error": "subscription_required",
344
+ "message": error_data.get("message", "This feature is not available in your current plan"),
345
+ "upgrade_url": error_data.get("upgrade_url", "https://pipeboard.co/upgrade"),
346
+ "suggestion": error_data.get("suggestion", "Please upgrade your account to access this feature")
347
+ }, indent=2))
348
+ except DuplicationError:
349
+ raise
350
+ except:
351
+ raise DuplicationError(json.dumps({
352
+ "success": False,
353
+ "error": "subscription_required",
354
+ "message": "This feature is not available in your current plan",
355
+ "upgrade_url": "https://pipeboard.co/upgrade",
356
+ "suggestion": "Please upgrade your account to access this feature"
357
+ }, indent=2))
358
+ elif response.status_code == 403:
359
+ try:
360
+ error_data = response.json()
361
+ # Check if this is a premium feature error
362
+ if error_data.get("error") == "premium_feature":
363
+ raise DuplicationError(json.dumps({
364
+ "success": False,
365
+ "error": "premium_feature_required",
366
+ "message": error_data.get("message", "This is a premium feature that requires subscription"),
367
+ "details": error_data.get("details", {
368
+ "upgrade_url": "https://pipeboard.co/upgrade",
369
+ "suggestion": "Please upgrade your account to access this feature"
370
+ })
371
+ }, indent=2))
372
+ else:
373
+ # Default to facebook connection required
374
+ raise DuplicationError(json.dumps({
375
+ "success": False,
376
+ "error": "facebook_connection_required",
377
+ "message": error_data.get("message", "You need to connect your Facebook account first"),
378
+ "details": error_data.get("details", {
379
+ "login_flow_url": "/connections",
380
+ "auth_flow_url": "/api/meta/auth"
381
+ })
382
+ }, indent=2))
383
+ except DuplicationError:
384
+ raise
385
+ except:
386
+ raise DuplicationError(json.dumps({
387
+ "success": False,
388
+ "error": "facebook_connection_required",
389
+ "message": "You need to connect your Facebook account first",
390
+ "details": {
391
+ "login_flow_url": "/connections",
392
+ "auth_flow_url": "/api/meta/auth"
393
+ }
394
+ }, indent=2))
395
+ elif response.status_code == 404:
396
+ raise DuplicationError(json.dumps({
397
+ "success": False,
398
+ "error": "resource_not_found",
399
+ "message": f"{resource_type.title()} not found or access denied",
400
+ "suggestion": f"Verify the {resource_type} ID and your Facebook account permissions"
401
+ }, indent=2))
402
+ elif response.status_code == 429:
403
+ # Raise so FastMCP sets isError: true in MCP response,
404
+ # enabling the Next.js proxy to detect and retry.
405
+ raise RateLimitError(json.dumps({
406
+ "error": "rate_limit_exceeded",
407
+ "message": "Meta API rate limit exceeded",
408
+ "details": {
409
+ "suggestion": "Please wait before retrying",
410
+ "retry_after": response.headers.get("Retry-After", "60")
411
+ }
412
+ }, indent=2))
413
+ elif response.status_code == 502:
414
+ try:
415
+ error_data = response.json()
416
+ raise DuplicationError(json.dumps({
417
+ "success": False,
418
+ "error": "meta_api_error",
419
+ "message": error_data.get("message", "Facebook API error"),
420
+ "recoverable": True,
421
+ "suggestion": "Please wait 5 minutes before retrying"
422
+ }, indent=2))
423
+ except DuplicationError:
424
+ raise
425
+ except:
426
+ raise DuplicationError(json.dumps({
427
+ "success": False,
428
+ "error": "meta_api_error",
429
+ "message": "Facebook API error",
430
+ "recoverable": True,
431
+ "suggestion": "Please wait 5 minutes before retrying"
432
+ }, indent=2))
433
+ else:
434
+ error_detail = response.text
435
+ error_json = None
436
+ try:
437
+ error_json = response.json()
438
+ error_detail = error_json.get("message", error_detail)
439
+ except:
440
+ pass
441
+
442
+ result = {
443
+ "error": error_json.get("error", "duplication_failed") if error_json else "duplication_failed",
444
+ "message": error_json.get("message", f"Failed to duplicate {resource_type}") if error_json else f"Failed to duplicate {resource_type}",
445
+ "details": {
446
+ "status_code": response.status_code,
447
+ "error_detail": error_detail,
448
+ "resource_type": resource_type,
449
+ "resource_id": resource_id
450
+ }
451
+ }
452
+
453
+ # Forward structured error fields from the API response
454
+ if error_json:
455
+ for field in ("suggestion", "error_subcode", "error_user_title",
456
+ "error_user_msg", "raw_code", "raw_type", "recoverable"):
457
+ if error_json.get(field) is not None:
458
+ result[field] = error_json[field]
459
+
460
+ raise DuplicationError(json.dumps(result, indent=2))
461
+
462
+ except httpx.TimeoutException:
463
+ raise DuplicationError(json.dumps({
464
+ "error": "request_timeout",
465
+ "message": "Request to duplication service timed out",
466
+ "details": {
467
+ "suggestion": "Please try again later",
468
+ "timeout": "120 seconds"
469
+ }
470
+ }, indent=2))
471
+
472
+ except httpx.RequestError as e:
473
+ raise DuplicationError(json.dumps({
474
+ "error": "network_error",
475
+ "message": "Failed to connect to duplication service",
476
+ "details": {
477
+ "error": str(e),
478
+ "suggestion": "Check your internet connection and try again"
479
+ }
480
+ }, indent=2))
481
+
482
+ except (RateLimitError, DuplicationError):
483
+ raise # Let FastMCP handle these to set isError: true
484
+
485
+ except Exception as e:
486
+ raise DuplicationError(json.dumps({
487
+ "error": "unexpected_error",
488
+ "message": f"Unexpected error during {resource_type} duplication",
489
+ "details": {
490
+ "error": str(e),
491
+ "resource_type": resource_type,
492
+ "resource_id": resource_id
493
+ }
494
+ }, indent=2))
495
+
496
+
497
+ def _get_estimated_components(resource_type: str, options: Dict[str, Any]) -> Dict[str, Any]:
498
+ """Get estimated components that would be duplicated."""
499
+ if resource_type == "campaign":
500
+ components = {"campaigns": 1}
501
+ if options.get("include_ad_sets", True):
502
+ components["ad_sets"] = "3-5 (estimated)"
503
+ if options.get("include_ads", True):
504
+ components["ads"] = "5-15 (estimated)"
505
+ if options.get("include_creatives", True):
506
+ components["creatives"] = "5-15 (estimated)"
507
+ return components
508
+ elif resource_type == "adset":
509
+ components = {"ad_sets": 1}
510
+ if options.get("include_ads", True):
511
+ components["ads"] = "2-5 (estimated)"
512
+ if options.get("include_creatives", True):
513
+ components["creatives"] = "2-5 (estimated)"
514
+ return components
515
+ elif resource_type == "ad":
516
+ components = {"ads": 1}
517
+ if options.get("duplicate_creative", True):
518
+ components["creatives"] = 1
519
+ return components
520
+ elif resource_type == "creative":
521
+ return {"creatives": 1}
522
+
523
+ return {}