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.
- meta_ads_mcp/__init__.py +79 -0
- meta_ads_mcp/__main__.py +10 -0
- meta_ads_mcp/core/__init__.py +55 -0
- meta_ads_mcp/core/accounts.py +141 -0
- meta_ads_mcp/core/ads.py +2751 -0
- meta_ads_mcp/core/ads_library.py +74 -0
- meta_ads_mcp/core/adsets.py +666 -0
- meta_ads_mcp/core/api.py +431 -0
- meta_ads_mcp/core/auth.py +567 -0
- meta_ads_mcp/core/authentication.py +207 -0
- meta_ads_mcp/core/budget_schedules.py +70 -0
- meta_ads_mcp/core/callback_server.py +256 -0
- meta_ads_mcp/core/campaigns.py +379 -0
- meta_ads_mcp/core/duplication.py +523 -0
- meta_ads_mcp/core/http_auth_integration.py +307 -0
- meta_ads_mcp/core/insights.py +161 -0
- meta_ads_mcp/core/mcc.py +232 -0
- meta_ads_mcp/core/openai_deep_research.py +418 -0
- meta_ads_mcp/core/pipeboard_auth.py +510 -0
- meta_ads_mcp/core/reports.py +135 -0
- meta_ads_mcp/core/resources.py +46 -0
- meta_ads_mcp/core/server.py +391 -0
- meta_ads_mcp/core/targeting.py +542 -0
- meta_ads_mcp/core/utils.py +225 -0
- meta_ads_mcp/settings.py +33 -0
- meta_ads_mcp_python-1.0.79.dist-info/METADATA +187 -0
- meta_ads_mcp_python-1.0.79.dist-info/RECORD +29 -0
- meta_ads_mcp_python-1.0.79.dist-info/WHEEL +4 -0
- meta_ads_mcp_python-1.0.79.dist-info/entry_points.txt +3 -0
|
@@ -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 {}
|