meta-ads-mcp 0.3.10__py3-none-any.whl → 0.4.1__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 CHANGED
@@ -7,7 +7,7 @@ with the Claude LLM.
7
7
 
8
8
  from meta_ads_mcp.core.server import main
9
9
 
10
- __version__ = "0.3.10"
10
+ __version__ = "0.4.1"
11
11
 
12
12
  __all__ = [
13
13
  'get_ad_accounts',
@@ -24,7 +24,6 @@ __all__ = [
24
24
  'get_ad_image',
25
25
  'update_ad',
26
26
  'get_insights',
27
- 'debug_image_download',
28
27
  'get_login_link',
29
28
  'login_cli',
30
29
  'main'
@@ -46,7 +45,6 @@ from .core import (
46
45
  get_ad_image,
47
46
  update_ad,
48
47
  get_insights,
49
- debug_image_download,
50
48
  get_login_link,
51
49
  login_cli,
52
50
  main
@@ -5,13 +5,14 @@ from .accounts import get_ad_accounts, get_account_info
5
5
  from .campaigns import get_campaigns, get_campaign_details, create_campaign
6
6
  from .adsets import get_adsets, get_adset_details, update_adset
7
7
  from .ads import get_ads, get_ad_details, get_ad_creatives, get_ad_image, update_ad
8
- from .insights import get_insights, debug_image_download
8
+ from .insights import get_insights
9
9
  from .authentication import get_login_link
10
10
  from .server import login_cli, main
11
11
  from .auth import login
12
12
  from .ads_library import search_ads_archive
13
13
  from .budget_schedules import create_budget_schedule
14
14
  from . import reports # Import module to register conditional tools
15
+ from . import duplication # Import module to register conditional duplication tools
15
16
 
16
17
  __all__ = [
17
18
  'mcp_server',
@@ -29,7 +30,6 @@ __all__ = [
29
30
  'get_ad_image',
30
31
  'update_ad',
31
32
  'get_insights',
32
- 'debug_image_download',
33
33
  'get_login_link',
34
34
  'login_cli',
35
35
  'login',
@@ -270,12 +270,21 @@ async def update_adset(adset_id: str, frequency_control_specs: List[Dict[str, An
270
270
  current_details = json.loads(current_details_json)
271
271
 
272
272
  # Start the callback server if not already running
273
- port = start_callback_server()
274
-
275
- # Generate confirmation URL with properly encoded parameters
276
- changes_json = json.dumps(changes)
277
- encoded_changes = urllib.parse.quote(changes_json)
278
- confirmation_url = f"http://localhost:{port}/confirm-update?adset_id={adset_id}&token={access_token}&changes={encoded_changes}"
273
+ try:
274
+ port = start_callback_server()
275
+
276
+ # Generate confirmation URL with properly encoded parameters
277
+ changes_json = json.dumps(changes)
278
+ encoded_changes = urllib.parse.quote(changes_json)
279
+ confirmation_url = f"http://localhost:{port}/confirm-update?adset_id={adset_id}&token={access_token}&changes={encoded_changes}"
280
+ except Exception as e:
281
+ return json.dumps({
282
+ "error": "Callback server disabled",
283
+ "message": f"Cannot create confirmation URL: {str(e)}",
284
+ "suggestion": "Manual update confirmation not available when META_ADS_DISABLE_CALLBACK_SERVER is set",
285
+ "adset_id": adset_id,
286
+ "proposed_changes": changes
287
+ }, indent=2)
279
288
 
280
289
  # Reset the update confirmation
281
290
  update_confirmation.clear()
meta_ads_mcp/core/auth.py CHANGED
@@ -216,22 +216,27 @@ class AuthManager:
216
216
  return self.token_info.access_token
217
217
 
218
218
  # Start the callback server if not already running
219
- port = start_callback_server()
220
-
221
- # Update redirect URI with the actual port
222
- self.redirect_uri = f"http://localhost:{port}/callback"
223
-
224
- # Generate the auth URL
225
- auth_url = self.get_auth_url()
226
-
227
- # Open browser with auth URL
228
- logger.info(f"Opening browser with URL: {auth_url}")
229
- webbrowser.open(auth_url)
230
-
231
- # We don't wait for the token here anymore
232
- # The token will be processed by the callback server
233
- # Just return None to indicate we've started the flow
234
- return None
219
+ try:
220
+ port = start_callback_server()
221
+
222
+ # Update redirect URI with the actual port
223
+ self.redirect_uri = f"http://localhost:{port}/callback"
224
+
225
+ # Generate the auth URL
226
+ auth_url = self.get_auth_url()
227
+
228
+ # Open browser with auth URL
229
+ logger.info(f"Opening browser with URL: {auth_url}")
230
+ webbrowser.open(auth_url)
231
+
232
+ # We don't wait for the token here anymore
233
+ # The token will be processed by the callback server
234
+ # Just return None to indicate we've started the flow
235
+ return None
236
+ except Exception as e:
237
+ logger.error(f"Failed to start callback server: {e}")
238
+ logger.info("Callback server disabled. OAuth authentication flow cannot be used.")
239
+ return None
235
240
 
236
241
  def get_access_token(self) -> Optional[str]:
237
242
  """
@@ -477,7 +482,14 @@ def login():
477
482
 
478
483
  try:
479
484
  # Start the callback server first
480
- port = start_callback_server()
485
+ try:
486
+ port = start_callback_server()
487
+ except Exception as callback_error:
488
+ print(f"Error: {callback_error}")
489
+ print("Callback server is disabled. Please use alternative authentication methods:")
490
+ print("- Set PIPEBOARD_API_TOKEN environment variable for Pipeboard authentication")
491
+ print("- Or provide a direct META_ACCESS_TOKEN environment variable")
492
+ return
481
493
 
482
494
  # Get the auth URL and open the browser
483
495
  auth_url = auth_manager.get_auth_url()
@@ -90,14 +90,23 @@ async def get_login_link(access_token: str = None) -> str:
90
90
  # IMPORTANT: Start the callback server first by calling our helper function
91
91
  # This ensures the server is ready before we provide the URL to the user
92
92
  logger.info("Starting callback server for authentication")
93
- port = start_callback_server()
94
- logger.info(f"Callback server started on port {port}")
95
-
96
- # Generate direct login URL
97
- auth_manager.redirect_uri = f"http://localhost:{port}/callback" # Ensure port is set correctly
98
- logger.info(f"Setting redirect URI to {auth_manager.redirect_uri}")
99
- login_url = auth_manager.get_auth_url()
100
- logger.info(f"Generated login URL: {login_url}")
93
+ try:
94
+ port = start_callback_server()
95
+ logger.info(f"Callback server started on port {port}")
96
+
97
+ # Generate direct login URL
98
+ auth_manager.redirect_uri = f"http://localhost:{port}/callback" # Ensure port is set correctly
99
+ logger.info(f"Setting redirect URI to {auth_manager.redirect_uri}")
100
+ login_url = auth_manager.get_auth_url()
101
+ logger.info(f"Generated login URL: {login_url}")
102
+ except Exception as e:
103
+ logger.error(f"Failed to start callback server: {e}")
104
+ return json.dumps({
105
+ "error": "Callback server disabled",
106
+ "message": str(e),
107
+ "suggestion": "Use Pipeboard authentication (set PIPEBOARD_API_TOKEN) or provide a direct access token",
108
+ "authentication_method": "meta_oauth_disabled"
109
+ }, indent=2)
101
110
 
102
111
  # Check if we can exchange for long-lived tokens
103
112
  token_exchange_supported = bool(META_APP_SECRET)
@@ -917,7 +917,16 @@ def start_callback_server() -> int:
917
917
 
918
918
  Returns:
919
919
  Port number the server is running on
920
+
921
+ Raises:
922
+ Exception: If callback server is disabled via META_ADS_DISABLE_CALLBACK_SERVER environment variable
920
923
  """
924
+ # Check if callback server is disabled via environment variable
925
+ if os.environ.get("META_ADS_DISABLE_CALLBACK_SERVER"):
926
+ logger.info("Callback server disabled via META_ADS_DISABLE_CALLBACK_SERVER environment variable")
927
+ print("Callback server is disabled. OAuth authentication flow cannot be used.")
928
+ raise Exception("Callback server disabled via META_ADS_DISABLE_CALLBACK_SERVER environment variable. Use alternative authentication methods.")
929
+
921
930
  global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
922
931
 
923
932
  with callback_server_lock:
@@ -0,0 +1,411 @@
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
9
+
10
+
11
+ # Only register the duplication functions if the environment variable is set
12
+ ENABLE_DUPLICATION = bool(os.environ.get("META_ADS_ENABLE_DUPLICATION", ""))
13
+
14
+ if ENABLE_DUPLICATION:
15
+ @mcp_server.tool()
16
+ @meta_api_tool
17
+ async def duplicate_campaign(
18
+ campaign_id: str,
19
+ access_token: str = None,
20
+ name_suffix: Optional[str] = " - Copy",
21
+ include_ad_sets: bool = True,
22
+ include_ads: bool = True,
23
+ include_creatives: bool = True,
24
+ copy_schedule: bool = False,
25
+ new_daily_budget: Optional[float] = None,
26
+ new_status: Optional[str] = "PAUSED"
27
+ ) -> str:
28
+ """
29
+ Duplicate a Meta Ads campaign with all its ad sets and ads.
30
+
31
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
32
+
33
+ Args:
34
+ campaign_id: Meta Ads campaign ID to duplicate
35
+ name_suffix: Suffix to add to the duplicated campaign name
36
+ include_ad_sets: Whether to duplicate ad sets within the campaign
37
+ include_ads: Whether to duplicate ads within ad sets
38
+ include_creatives: Whether to duplicate ad creatives
39
+ copy_schedule: Whether to copy the campaign schedule
40
+ new_daily_budget: Override the daily budget for the new campaign
41
+ new_status: Status for the new campaign (ACTIVE or PAUSED)
42
+ """
43
+ return await _forward_duplication_request(
44
+ "campaign",
45
+ campaign_id,
46
+ access_token,
47
+ {
48
+ "name_suffix": name_suffix,
49
+ "include_ad_sets": include_ad_sets,
50
+ "include_ads": include_ads,
51
+ "include_creatives": include_creatives,
52
+ "copy_schedule": copy_schedule,
53
+ "new_daily_budget": new_daily_budget,
54
+ "new_status": new_status
55
+ }
56
+ )
57
+
58
+ @mcp_server.tool()
59
+ @meta_api_tool
60
+ async def duplicate_adset(
61
+ adset_id: str,
62
+ access_token: str = None,
63
+ target_campaign_id: Optional[str] = None,
64
+ name_suffix: Optional[str] = " - Copy",
65
+ include_ads: bool = True,
66
+ include_creatives: bool = True,
67
+ new_daily_budget: Optional[float] = None,
68
+ new_targeting: Optional[Dict[str, Any]] = None,
69
+ new_status: Optional[str] = "PAUSED"
70
+ ) -> str:
71
+ """
72
+ Duplicate a Meta Ads ad set with its ads.
73
+
74
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
75
+
76
+ Args:
77
+ adset_id: Meta Ads ad set ID to duplicate
78
+ target_campaign_id: Campaign ID to move the duplicated ad set to (optional)
79
+ name_suffix: Suffix to add to the duplicated ad set name
80
+ include_ads: Whether to duplicate ads within the ad set
81
+ include_creatives: Whether to duplicate ad creatives
82
+ new_daily_budget: Override the daily budget for the new ad set
83
+ new_targeting: Override targeting settings for the new ad set
84
+ new_status: Status for the new ad set (ACTIVE or PAUSED)
85
+ """
86
+ return await _forward_duplication_request(
87
+ "adset",
88
+ adset_id,
89
+ access_token,
90
+ {
91
+ "target_campaign_id": target_campaign_id,
92
+ "name_suffix": name_suffix,
93
+ "include_ads": include_ads,
94
+ "include_creatives": include_creatives,
95
+ "new_daily_budget": new_daily_budget,
96
+ "new_targeting": new_targeting,
97
+ "new_status": new_status
98
+ }
99
+ )
100
+
101
+ @mcp_server.tool()
102
+ @meta_api_tool
103
+ async def duplicate_ad(
104
+ ad_id: str,
105
+ access_token: str = None,
106
+ target_adset_id: Optional[str] = None,
107
+ name_suffix: Optional[str] = " - Copy",
108
+ duplicate_creative: bool = True,
109
+ new_creative_name: Optional[str] = None,
110
+ new_status: Optional[str] = "PAUSED"
111
+ ) -> str:
112
+ """
113
+ Duplicate a Meta Ads ad.
114
+
115
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
116
+
117
+ Args:
118
+ ad_id: Meta Ads ad ID to duplicate
119
+ target_adset_id: Ad set ID to move the duplicated ad to (optional)
120
+ name_suffix: Suffix to add to the duplicated ad name
121
+ duplicate_creative: Whether to duplicate the ad creative
122
+ new_creative_name: Override name for the duplicated creative
123
+ new_status: Status for the new ad (ACTIVE or PAUSED)
124
+ """
125
+ return await _forward_duplication_request(
126
+ "ad",
127
+ ad_id,
128
+ access_token,
129
+ {
130
+ "target_adset_id": target_adset_id,
131
+ "name_suffix": name_suffix,
132
+ "duplicate_creative": duplicate_creative,
133
+ "new_creative_name": new_creative_name,
134
+ "new_status": new_status
135
+ }
136
+ )
137
+
138
+ @mcp_server.tool()
139
+ @meta_api_tool
140
+ async def duplicate_creative(
141
+ creative_id: str,
142
+ access_token: str = None,
143
+ name_suffix: Optional[str] = " - Copy",
144
+ new_primary_text: Optional[str] = None,
145
+ new_headline: Optional[str] = None,
146
+ new_description: Optional[str] = None,
147
+ new_cta_type: Optional[str] = None,
148
+ new_destination_url: Optional[str] = None
149
+ ) -> str:
150
+ """
151
+ Duplicate a Meta Ads creative.
152
+
153
+ **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
154
+
155
+ Args:
156
+ creative_id: Meta Ads creative ID to duplicate
157
+ name_suffix: Suffix to add to the duplicated creative name
158
+ new_primary_text: Override the primary text for the new creative
159
+ new_headline: Override the headline for the new creative
160
+ new_description: Override the description for the new creative
161
+ new_cta_type: Override the call-to-action type for the new creative
162
+ new_destination_url: Override the destination URL for the new creative
163
+ """
164
+ return await _forward_duplication_request(
165
+ "creative",
166
+ creative_id,
167
+ access_token,
168
+ {
169
+ "name_suffix": name_suffix,
170
+ "new_primary_text": new_primary_text,
171
+ "new_headline": new_headline,
172
+ "new_description": new_description,
173
+ "new_cta_type": new_cta_type,
174
+ "new_destination_url": new_destination_url
175
+ }
176
+ )
177
+
178
+
179
+ async def _forward_duplication_request(resource_type: str, resource_id: str, access_token: str, options: Dict[str, Any]) -> str:
180
+ """
181
+ Forward duplication request to the cloud-hosted MCP API.
182
+
183
+ Args:
184
+ resource_type: Type of resource to duplicate (campaign, adset, ad, creative)
185
+ resource_id: ID of the resource to duplicate
186
+ access_token: Meta API access token from the request
187
+ options: Duplication options
188
+ """
189
+ try:
190
+ if not access_token:
191
+ return json.dumps({
192
+ "error": "authentication_required",
193
+ "message": "Meta Ads access token not found",
194
+ "details": {
195
+ "required": "Valid access token from authenticated session"
196
+ }
197
+ }, indent=2)
198
+
199
+ # Construct the API endpoint
200
+ base_url = "https://mcp.pipeboard.co"
201
+ endpoint = f"{base_url}/api/meta/duplicate/{resource_type}/{resource_id}"
202
+
203
+ # Prepare the request
204
+ headers = {
205
+ "Authorization": f"Bearer {access_token}",
206
+ "Content-Type": "application/json",
207
+ "User-Agent": "meta-ads-mcp/1.0"
208
+ }
209
+
210
+ # Remove None values from options
211
+ clean_options = {k: v for k, v in options.items() if v is not None}
212
+
213
+ # Make the request to the cloud service
214
+ async with httpx.AsyncClient(timeout=30.0) as client:
215
+ response = await client.post(
216
+ endpoint,
217
+ headers=headers,
218
+ json=clean_options
219
+ )
220
+
221
+ if response.status_code == 200:
222
+ result = response.json()
223
+ return json.dumps(result, indent=2)
224
+ elif response.status_code == 400:
225
+ # Validation failed
226
+ try:
227
+ error_data = response.json()
228
+ return json.dumps({
229
+ "success": False,
230
+ "error": "validation_failed",
231
+ "errors": error_data.get("errors", [response.text]),
232
+ "warnings": error_data.get("warnings", [])
233
+ }, indent=2)
234
+ except:
235
+ return json.dumps({
236
+ "success": False,
237
+ "error": "validation_failed",
238
+ "errors": [response.text],
239
+ "warnings": []
240
+ }, indent=2)
241
+ elif response.status_code == 401:
242
+ return json.dumps({
243
+ "success": False,
244
+ "error": "authentication_error",
245
+ "message": "Invalid or expired API token"
246
+ }, indent=2)
247
+ elif response.status_code == 402:
248
+ try:
249
+ error_data = response.json()
250
+ return json.dumps({
251
+ "success": False,
252
+ "error": "subscription_required",
253
+ "message": error_data.get("message", "This feature is not available in your current plan"),
254
+ "upgrade_url": error_data.get("upgrade_url", "https://pipeboard.co/upgrade"),
255
+ "suggestion": error_data.get("suggestion", "Please upgrade your account to access this feature")
256
+ }, indent=2)
257
+ except:
258
+ return json.dumps({
259
+ "success": False,
260
+ "error": "subscription_required",
261
+ "message": "This feature is not available in your current plan",
262
+ "upgrade_url": "https://pipeboard.co/upgrade",
263
+ "suggestion": "Please upgrade your account to access this feature"
264
+ }, indent=2)
265
+ elif response.status_code == 403:
266
+ try:
267
+ error_data = response.json()
268
+ # Check if this is a premium feature error
269
+ if error_data.get("error") == "premium_feature":
270
+ return json.dumps({
271
+ "success": False,
272
+ "error": "premium_feature_required",
273
+ "message": error_data.get("message", "This is a premium feature that requires subscription"),
274
+ "details": error_data.get("details", {
275
+ "upgrade_url": "https://pipeboard.co/upgrade",
276
+ "suggestion": "Please upgrade your account to access this feature"
277
+ })
278
+ }, indent=2)
279
+ else:
280
+ # Default to facebook connection required
281
+ return json.dumps({
282
+ "success": False,
283
+ "error": "facebook_connection_required",
284
+ "message": error_data.get("message", "You need to connect your Facebook account first"),
285
+ "details": error_data.get("details", {
286
+ "login_flow_url": "/connections",
287
+ "auth_flow_url": "/api/meta/auth"
288
+ })
289
+ }, indent=2)
290
+ except:
291
+ return json.dumps({
292
+ "success": False,
293
+ "error": "facebook_connection_required",
294
+ "message": "You need to connect your Facebook account first",
295
+ "details": {
296
+ "login_flow_url": "/connections",
297
+ "auth_flow_url": "/api/meta/auth"
298
+ }
299
+ }, indent=2)
300
+ elif response.status_code == 404:
301
+ return json.dumps({
302
+ "success": False,
303
+ "error": "resource_not_found",
304
+ "message": f"{resource_type.title()} not found or access denied",
305
+ "suggestion": f"Verify the {resource_type} ID and your Facebook account permissions"
306
+ }, indent=2)
307
+ elif response.status_code == 429:
308
+ return json.dumps({
309
+ "error": "rate_limit_exceeded",
310
+ "message": "Meta API rate limit exceeded",
311
+ "details": {
312
+ "suggestion": "Please wait before retrying",
313
+ "retry_after": response.headers.get("Retry-After", "60")
314
+ }
315
+ }, indent=2)
316
+ elif response.status_code == 502:
317
+ try:
318
+ error_data = response.json()
319
+ return json.dumps({
320
+ "success": False,
321
+ "error": "meta_api_error",
322
+ "message": error_data.get("message", "Facebook API error"),
323
+ "recoverable": True,
324
+ "suggestion": "Please wait 5 minutes before retrying"
325
+ }, indent=2)
326
+ except:
327
+ return json.dumps({
328
+ "success": False,
329
+ "error": "meta_api_error",
330
+ "message": "Facebook API error",
331
+ "recoverable": True,
332
+ "suggestion": "Please wait 5 minutes before retrying"
333
+ }, indent=2)
334
+ else:
335
+ error_detail = response.text
336
+ try:
337
+ error_json = response.json()
338
+ error_detail = error_json.get("message", error_detail)
339
+ except:
340
+ pass
341
+
342
+ return json.dumps({
343
+ "error": "duplication_failed",
344
+ "message": f"Failed to duplicate {resource_type}",
345
+ "details": {
346
+ "status_code": response.status_code,
347
+ "error_detail": error_detail,
348
+ "resource_type": resource_type,
349
+ "resource_id": resource_id
350
+ }
351
+ }, indent=2)
352
+
353
+ except httpx.TimeoutException:
354
+ return json.dumps({
355
+ "error": "request_timeout",
356
+ "message": "Request to duplication service timed out",
357
+ "details": {
358
+ "suggestion": "Please try again later",
359
+ "timeout": "30 seconds"
360
+ }
361
+ }, indent=2)
362
+
363
+ except httpx.RequestError as e:
364
+ return json.dumps({
365
+ "error": "network_error",
366
+ "message": "Failed to connect to duplication service",
367
+ "details": {
368
+ "error": str(e),
369
+ "suggestion": "Check your internet connection and try again"
370
+ }
371
+ }, indent=2)
372
+
373
+ except Exception as e:
374
+ return json.dumps({
375
+ "error": "unexpected_error",
376
+ "message": f"Unexpected error during {resource_type} duplication",
377
+ "details": {
378
+ "error": str(e),
379
+ "resource_type": resource_type,
380
+ "resource_id": resource_id
381
+ }
382
+ }, indent=2)
383
+
384
+
385
+ def _get_estimated_components(resource_type: str, options: Dict[str, Any]) -> Dict[str, Any]:
386
+ """Get estimated components that would be duplicated."""
387
+ if resource_type == "campaign":
388
+ components = {"campaigns": 1}
389
+ if options.get("include_ad_sets", True):
390
+ components["ad_sets"] = "3-5 (estimated)"
391
+ if options.get("include_ads", True):
392
+ components["ads"] = "5-15 (estimated)"
393
+ if options.get("include_creatives", True):
394
+ components["creatives"] = "5-15 (estimated)"
395
+ return components
396
+ elif resource_type == "adset":
397
+ components = {"ad_sets": 1}
398
+ if options.get("include_ads", True):
399
+ components["ads"] = "2-5 (estimated)"
400
+ if options.get("include_creatives", True):
401
+ components["creatives"] = "2-5 (estimated)"
402
+ return components
403
+ elif resource_type == "ad":
404
+ components = {"ads": 1}
405
+ if options.get("duplicate_creative", True):
406
+ components["creatives"] = 1
407
+ return components
408
+ elif resource_type == "creative":
409
+ return {"creatives": 1}
410
+
411
+ return {}
@@ -56,374 +56,7 @@ async def get_insights(access_token: str = None, object_id: str = None,
56
56
  return json.dumps(data, indent=2)
57
57
 
58
58
 
59
- @mcp_server.tool()
60
- @meta_api_tool
61
- async def debug_image_download(access_token: str = None, url: str = "", ad_id: str = "") -> str:
62
- """
63
- Debug image download issues and report detailed diagnostics.
64
-
65
- Args:
66
- access_token: Meta API access token (optional - will use cached token if not provided)
67
- url: Direct image URL to test (optional)
68
- ad_id: Meta Ads ad ID (optional, used if url is not provided)
69
- """
70
- results = {
71
- "diagnostics": {
72
- "timestamp": str(datetime.datetime.now()),
73
- "methods_tried": [],
74
- "request_details": [],
75
- "network_info": {}
76
- }
77
- }
78
-
79
- # If no URL provided but ad_id is, get URL from ad creative
80
- if not url and ad_id:
81
- print(f"Getting image URL from ad creative for ad {ad_id}")
82
- # Get the creative details
83
- from .ads import get_ad_creatives
84
- creative_json = await get_ad_creatives(ad_id=ad_id, access_token=access_token)
85
- creative_data = json.loads(creative_json)
86
- results["creative_data"] = creative_data
87
-
88
- # Look for image URL in the creative
89
- if "full_image_url" in creative_data:
90
- url = creative_data.get("full_image_url")
91
- elif "thumbnail_url" in creative_data:
92
- url = creative_data.get("thumbnail_url")
93
-
94
- if not url:
95
- return json.dumps({
96
- "error": "No image URL provided or found in ad creative",
97
- "results": results
98
- }, indent=2)
99
-
100
- results["image_url"] = url
101
-
102
- # Try to get network information to help debug
103
- try:
104
- import socket
105
- from urllib.parse import urlparse
106
- hostname = urlparse(url).netloc
107
- ip_address = socket.gethostbyname(hostname)
108
- results["diagnostics"]["network_info"] = {
109
- "hostname": hostname,
110
- "ip_address": ip_address,
111
- "is_facebook_cdn": "fbcdn" in hostname
112
- }
113
- except Exception as e:
114
- results["diagnostics"]["network_info"] = {
115
- "error": str(e)
116
- }
117
-
118
- # Method 1: Basic download
119
- method_result = {
120
- "method": "Basic download with standard headers",
121
- "success": False
122
- }
123
- results["diagnostics"]["methods_tried"].append(method_result)
124
-
125
- try:
126
- headers = {
127
- "User-Agent": "curl/8.4.0"
128
- }
129
- import httpx
130
- async with httpx.AsyncClient(follow_redirects=True) as client:
131
- response = await client.get(url, headers=headers, timeout=30.0)
132
- method_result["status_code"] = response.status_code
133
- method_result["headers"] = dict(response.headers)
134
-
135
- if response.status_code == 200:
136
- method_result["success"] = True
137
- method_result["content_length"] = len(response.content)
138
- method_result["content_type"] = response.headers.get("content-type")
139
-
140
- # Save this successful result
141
- results["image_data"] = {
142
- "length": len(response.content),
143
- "type": response.headers.get("content-type"),
144
- "base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
145
- }
146
- except Exception as e:
147
- method_result["error"] = str(e)
148
-
149
- # Method 2: Browser emulation
150
- method_result = {
151
- "method": "Browser emulation with cookies",
152
- "success": False
153
- }
154
- results["diagnostics"]["methods_tried"].append(method_result)
155
-
156
- try:
157
- headers = {
158
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
159
- "Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
160
- "Accept-Language": "en-US,en;q=0.9",
161
- "Referer": "https://www.facebook.com/",
162
- "Cookie": "presence=EDvF3EtimeF1697900316EuserFA21B00112233445566AA0EstateFDutF0CEchF_7bCC"
163
- }
164
-
165
- import httpx
166
- async with httpx.AsyncClient(follow_redirects=True) as client:
167
- response = await client.get(url, headers=headers, timeout=30.0)
168
- method_result["status_code"] = response.status_code
169
- method_result["headers"] = dict(response.headers)
170
-
171
- if response.status_code == 200:
172
- method_result["success"] = True
173
- method_result["content_length"] = len(response.content)
174
- method_result["content_type"] = response.headers.get("content-type")
175
-
176
- # If first method didn't succeed, save this successful result
177
- if "image_data" not in results:
178
- results["image_data"] = {
179
- "length": len(response.content),
180
- "type": response.headers.get("content-type"),
181
- "base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
182
- }
183
- except Exception as e:
184
- method_result["error"] = str(e)
185
-
186
- # Method 3: Graph API direct access (if applicable)
187
- if "fbcdn" in url or "facebook" in url:
188
- method_result = {
189
- "method": "Graph API direct access",
190
- "success": False
191
- }
192
- results["diagnostics"]["methods_tried"].append(method_result)
193
-
194
- try:
195
- # Try to reconstruct the attachment ID from URL if possible
196
- from urllib.parse import urlparse
197
- url_parts = urlparse(url).path.split("/")
198
- potential_ids = [part for part in url_parts if part.isdigit() and len(part) > 10]
199
-
200
- if potential_ids:
201
- attachment_id = potential_ids[0]
202
- endpoint = f"{attachment_id}?fields=url,width,height"
203
- api_result = await make_api_request(endpoint, access_token)
204
-
205
- method_result["api_response"] = api_result
206
-
207
- if "url" in api_result:
208
- graph_url = api_result["url"]
209
- method_result["graph_url"] = graph_url
210
-
211
- # Try to download from this Graph API URL
212
- import httpx
213
- async with httpx.AsyncClient() as client:
214
- response = await client.get(graph_url, timeout=30.0)
215
-
216
- method_result["status_code"] = response.status_code
217
- if response.status_code == 200:
218
- method_result["success"] = True
219
- method_result["content_length"] = len(response.content)
220
-
221
- # If previous methods didn't succeed, save this successful result
222
- if "image_data" not in results:
223
- results["image_data"] = {
224
- "length": len(response.content),
225
- "type": response.headers.get("content-type"),
226
- "base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
227
- }
228
- except Exception as e:
229
- method_result["error"] = str(e)
230
-
231
- # Generate a recommendation based on what we found
232
- if "image_data" in results:
233
- results["recommendation"] = "At least one download method succeeded. Consider implementing the successful method in the main code."
234
- else:
235
- # Check if the error appears to be access-related
236
- access_errors = False
237
- for method in results["diagnostics"]["methods_tried"]:
238
- if method.get("status_code") in [401, 403, 503]:
239
- access_errors = True
240
-
241
- if access_errors:
242
- results["recommendation"] = "Authentication or authorization errors detected. Images may require direct Facebook authentication not possible via API."
243
- else:
244
- results["recommendation"] = "Network or other technical errors detected. Check URL expiration or CDN restrictions."
245
-
246
- return json.dumps(results, indent=2)
247
59
 
248
60
 
249
- @mcp_server.tool()
250
- @meta_api_tool
251
- async def save_ad_image_via_api(access_token: str = None, ad_id: str = None) -> str:
252
- """
253
- Try to save an ad image by using the Marketing API's attachment endpoints.
254
- This is an alternative approach when direct image download fails.
255
-
256
- Args:
257
- access_token: Meta API access token (optional - will use cached token if not provided)
258
- ad_id: Meta Ads ad ID
259
- """
260
- if not ad_id:
261
- return json.dumps({"error": "No ad ID provided"}, indent=2)
262
-
263
- # First get the ad's creative ID
264
- endpoint = f"{ad_id}"
265
- params = {
266
- "fields": "creative,account_id"
267
- }
268
-
269
- ad_data = await make_api_request(endpoint, access_token, params)
270
-
271
- if "error" in ad_data:
272
- return json.dumps({
273
- "error": "Could not get ad data",
274
- "details": ad_data
275
- }, indent=2)
276
-
277
- if "creative" not in ad_data or "id" not in ad_data["creative"]:
278
- return json.dumps({
279
- "error": "No creative ID found for this ad",
280
- "ad_data": ad_data
281
- }, indent=2)
282
-
283
- creative_id = ad_data["creative"]["id"]
284
- account_id = ad_data.get("account_id", "")
285
-
286
- # Now get the creative object
287
- creative_endpoint = f"{creative_id}"
288
- creative_params = {
289
- "fields": "id,name,thumbnail_url,image_hash,asset_feed_spec"
290
- }
291
-
292
- creative_data = await make_api_request(creative_endpoint, access_token, creative_params)
293
-
294
- if "error" in creative_data:
295
- return json.dumps({
296
- "error": "Could not get creative data",
297
- "details": creative_data
298
- }, indent=2)
299
-
300
- # Approach 1: Try to get image through adimages endpoint if we have image_hash
301
- image_hash = None
302
- if "image_hash" in creative_data:
303
- image_hash = creative_data["image_hash"]
304
- elif "asset_feed_spec" in creative_data and "images" in creative_data["asset_feed_spec"] and len(creative_data["asset_feed_spec"]["images"]) > 0:
305
- image_hash = creative_data["asset_feed_spec"]["images"][0].get("hash")
306
-
307
- result = {
308
- "ad_id": ad_id,
309
- "creative_id": creative_id,
310
- "attempts": []
311
- }
312
-
313
- if image_hash and account_id:
314
- attempt = {
315
- "method": "adimages endpoint with hash",
316
- "success": False
317
- }
318
- result["attempts"].append(attempt)
319
-
320
- try:
321
- image_endpoint = f"act_{account_id}/adimages"
322
- image_params = {
323
- "hashes": [image_hash]
324
- }
325
- image_data = await make_api_request(image_endpoint, access_token, image_params)
326
- attempt["response"] = image_data
327
-
328
- if "data" in image_data and len(image_data["data"]) > 0 and "url" in image_data["data"][0]:
329
- url = image_data["data"][0]["url"]
330
- attempt["url"] = url
331
-
332
- # Try to download the image
333
- image_bytes = await download_image(url)
334
- if image_bytes:
335
- attempt["success"] = True
336
- attempt["image_size"] = len(image_bytes)
337
-
338
- # Save the image
339
- resource_id = f"ad_creative_{ad_id}_method1"
340
- resource_info = create_resource_from_image(
341
- image_bytes,
342
- resource_id,
343
- f"Ad Creative for {ad_id} (Method 1)"
344
- )
345
-
346
- # Return success with resource info
347
- result.update(resource_info)
348
- result["success"] = True
349
- base64_sample = base64.b64encode(image_bytes[:100]).decode("utf-8") + "..."
350
- result["base64_sample"] = base64_sample
351
- except Exception as e:
352
- attempt["error"] = str(e)
353
-
354
- # Approach 2: Try directly with the thumbnails endpoint
355
- attempt = {
356
- "method": "thumbnails endpoint on creative",
357
- "success": False
358
- }
359
- result["attempts"].append(attempt)
360
-
361
- try:
362
- thumbnails_endpoint = f"{creative_id}/thumbnails"
363
- thumbnails_params = {}
364
- thumbnails_data = await make_api_request(thumbnails_endpoint, access_token, thumbnails_params)
365
- attempt["response"] = thumbnails_data
366
-
367
- if "data" in thumbnails_data and len(thumbnails_data["data"]) > 0:
368
- for thumbnail in thumbnails_data["data"]:
369
- if "uri" in thumbnail:
370
- url = thumbnail["uri"]
371
- attempt["url"] = url
372
-
373
- # Try to download the image
374
- image_bytes = await download_image(url)
375
- if image_bytes:
376
- attempt["success"] = True
377
- attempt["image_size"] = len(image_bytes)
378
-
379
- # Save the image if method 1 didn't already succeed
380
- if "success" not in result or not result["success"]:
381
- resource_id = f"ad_creative_{ad_id}_method2"
382
- resource_info = create_resource_from_image(
383
- image_bytes,
384
- resource_id,
385
- f"Ad Creative for {ad_id} (Method 2)"
386
- )
387
-
388
- # Return success with resource info
389
- result.update(resource_info)
390
- result["success"] = True
391
- base64_sample = base64.b64encode(image_bytes[:100]).decode("utf-8") + "..."
392
- result["base64_sample"] = base64_sample
393
-
394
- # No need to try more thumbnails if we succeeded
395
- break
396
- except Exception as e:
397
- attempt["error"] = str(e)
398
-
399
- # Approach 3: Try using the preview shareable link as an alternate source
400
- attempt = {
401
- "method": "preview_shareable_link",
402
- "success": False
403
- }
404
- result["attempts"].append(attempt)
405
-
406
- try:
407
- # Get ad details with preview link
408
- ad_preview_endpoint = f"{ad_id}"
409
- ad_preview_params = {
410
- "fields": "preview_shareable_link"
411
- }
412
- ad_preview_data = await make_api_request(ad_preview_endpoint, access_token, ad_preview_params)
413
-
414
- if "preview_shareable_link" in ad_preview_data:
415
- preview_link = ad_preview_data["preview_shareable_link"]
416
- attempt["preview_link"] = preview_link
417
-
418
- # We can't directly download the preview image, but let's note it for manual inspection
419
- attempt["note"] = "Preview link available for manual inspection in browser"
420
- except Exception as e:
421
- attempt["error"] = str(e)
422
-
423
- # Overall result
424
- if "success" in result and result["success"]:
425
- result["message"] = "Successfully retrieved ad image through one of the API methods"
426
- else:
427
- result["message"] = "Failed to retrieve ad image through any API method"
428
-
429
- return json.dumps(result, indent=2)
61
+
62
+
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-ads-mcp
3
- Version: 0.3.10
4
- Summary: Model Calling Protocol (MCP) plugin for interacting with Meta Ads API
3
+ Version: 0.4.1
4
+ Summary: Model Context Protocol (MCP) plugin for interacting with Meta Ads API
5
5
  Project-URL: Homepage, https://github.com/pipeboard-co/meta-ads-mcp
6
6
  Project-URL: Bug Tracker, https://github.com/pipeboard-co/meta-ads-mcp/issues
7
7
  Author-email: Yves Junqueira <yves.junqueira@gmail.com>
8
- License: MIT
8
+ License: Apache-2.0
9
9
  License-File: LICENSE
10
10
  Keywords: ads,api,claude,facebook,mcp,meta
11
- Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
12
  Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Requires-Python: >=3.10
@@ -16,6 +16,8 @@ Requires-Dist: httpx>=0.26.0
16
16
  Requires-Dist: mcp[cli]>=1.10.1
17
17
  Requires-Dist: pathlib>=1.0.1
18
18
  Requires-Dist: pillow>=10.0.0
19
+ Requires-Dist: pytest-asyncio>=1.0.0
20
+ Requires-Dist: pytest>=8.4.1
19
21
  Requires-Dist: python-dateutil>=2.8.2
20
22
  Requires-Dist: python-dotenv>=1.1.0
21
23
  Requires-Dist: requests>=2.32.3
@@ -63,17 +65,14 @@ That's it! You can now ask Claude to analyze your Meta ad campaigns, get perform
63
65
 
64
66
  ### For Cursor Users
65
67
 
66
- Add this to your `~/.cursor/mcp.json`:
68
+ Add the following to your `~/.cursor/mcp.json`. Once you enable the remote MCP, click on "Needs login" to finish the login process.
69
+
67
70
 
68
71
  ```json
69
72
  {
70
73
  "mcpServers": {
71
74
  "meta-ads-remote": {
72
- "command": "npx",
73
- "args": [
74
- "mcp-remote",
75
- "https://mcp.pipeboard.co/meta-ads-mcp"
76
- ]
75
+ "url": "https://mcp.pipeboard.co/meta-ads-mcp"
77
76
  }
78
77
  }
79
78
  }
@@ -313,21 +312,13 @@ For local installation configuration, authentication options, and advanced techn
313
312
  - `level`: Level of aggregation (ad, adset, campaign, account)
314
313
  - Returns: Performance metrics for the specified object
315
314
 
316
- 20. `mcp_meta_ads_debug_image_download`
317
- - Debug image download issues and report detailed diagnostics
318
- - Inputs:
319
- - `access_token` (optional): Meta API access token (will use cached token if not provided)
320
- - `url`: Direct image URL to test (optional)
321
- - `ad_id`: Meta Ads ad ID (optional, used if url is not provided)
322
- - Returns: Diagnostic information about image download attempts
323
-
324
- 21. `mcp_meta_ads_get_login_link`
315
+ 20. `mcp_meta_ads_get_login_link`
325
316
  - Get a clickable login link for Meta Ads authentication
326
317
  - Inputs:
327
318
  - `access_token` (optional): Meta API access token (will use cached token if not provided)
328
319
  - Returns: A clickable resource link for Meta authentication
329
320
 
330
- 22. `mcp_meta-ads_create_budget_schedule`
321
+ 21. `mcp_meta-ads_create_budget_schedule`
331
322
  - Create a budget schedule for a Meta Ads campaign.
332
323
  - Inputs:
333
324
  - `campaign_id`: Meta Ads campaign ID.
@@ -1,25 +1,26 @@
1
- meta_ads_mcp/__init__.py,sha256=eWjCQD9tlMl8HRDeUmrjlU0YxKfIjzyI00LB6SPO_VI,1237
1
+ meta_ads_mcp/__init__.py,sha256=L28BjkKhMZJU54HlrDum5EOovTvkzo5jSvFDE_g8QJw,1182
2
2
  meta_ads_mcp/__main__.py,sha256=XaQt3iXftG_7f0Zu7Wop9SeFgrD2WBn0EQOaPMc27d8,207
3
- meta_ads_mcp/core/__init__.py,sha256=J9vO4rweKb9J-os2maiUy2R4gZGUH4bDSszLDqDCY6w,1174
3
+ meta_ads_mcp/core/__init__.py,sha256=XVJjMOfdgnqxy3k8vCn2PCf7za8fMk4BdgJGiSFCVZY,1209
4
4
  meta_ads_mcp/core/accounts.py,sha256=Nmp7lPxO9wmq25jWV7_H0LIqnEbBhpCVBlLGW2HUaq0,2277
5
5
  meta_ads_mcp/core/ads.py,sha256=b_81GlGHIM4jISvuDZmHNyc6uW7uD3ovX68ezBci9MM,29747
6
6
  meta_ads_mcp/core/ads_library.py,sha256=onStn9UkRqYDC60gOPS-iKDtP1plz6DygUb7hUZ0Jw8,2807
7
- meta_ads_mcp/core/adsets.py,sha256=WBPNaI7ITnUOnGMus4_0MX15DslOCzfM5q1zF1VWs2s,12408
7
+ meta_ads_mcp/core/adsets.py,sha256=8m8RDsa1CmCb75-YXcMHDYNpa8J12ovtQPp0kgYHDk4,12823
8
8
  meta_ads_mcp/core/api.py,sha256=aAzM6Q75VQOFXtr5D-mDmBRhxWK4wsiODsJYnR3mpDI,14994
9
- meta_ads_mcp/core/auth.py,sha256=pDARBh3NBNqCpxflVrVvR4VsWuIveFxQmb9-P-gLFDM,20730
10
- meta_ads_mcp/core/authentication.py,sha256=PFqmN7ujtNsJCEDutDzs81peGWFJ8_0YLYI3-o8kmt4,6577
9
+ meta_ads_mcp/core/auth.py,sha256=z8HfLbDNB7IzoIcqt-lBGne6P97FF-dubO_cZNe5S_8,21425
10
+ meta_ads_mcp/core/authentication.py,sha256=4CH2Fe3w7Al7YE2wgoa0DW5qOXTp_5Lsa4T6_Rh55s0,7048
11
11
  meta_ads_mcp/core/budget_schedules.py,sha256=UxseExsvKAiPwfDCY9aycT4kys4xqeNytyq-yyDOxrs,2901
12
- meta_ads_mcp/core/callback_server.py,sha256=AUymElaVwHqFyqB2wgqf6A68KsqwtKoYmY-7JZZt8Ks,43286
12
+ meta_ads_mcp/core/callback_server.py,sha256=wNuxmj7YTFeSdVGi_iJ9vberNy3VdzBIP0uSsqn7g5Q,43888
13
13
  meta_ads_mcp/core/campaigns.py,sha256=Fd477GsD1Gx08Ve0uXUCvr4fC-xQCeVHPBwRVaeRQKk,10965
14
+ meta_ads_mcp/core/duplication.py,sha256=o9vYczBCiF7bnRZBUGjI2ib06z44E7e7kvJM44jr83k,17052
14
15
  meta_ads_mcp/core/http_auth_integration.py,sha256=ZJHuxK1Kwtr9gvwfC5HZOLH5MW-HnDDKqJc4xuG5yVE,10060
15
- meta_ads_mcp/core/insights.py,sha256=XAm4uu83gWp84PEGqAJ3GFIqlvg7prh6MdD71JfvBCo,18072
16
+ meta_ads_mcp/core/insights.py,sha256=U7KYdWQpGcdykE1WUtdJdYR3VTwKrXUzIzCREwWbf48,2599
16
17
  meta_ads_mcp/core/pipeboard_auth.py,sha256=VvbxEB8ZOhnMccLU7HI1HgaPWHCl5NGrzZCm-zzHze4,22798
17
18
  meta_ads_mcp/core/reports.py,sha256=Dv3hfsPOR7IZ9WrYrKd_6SNgZl-USIphg7knva3UYAw,5747
18
19
  meta_ads_mcp/core/resources.py,sha256=-zIIfZulpo76vcKv6jhAlQq91cR2SZ3cjYZt3ek3x0w,1236
19
20
  meta_ads_mcp/core/server.py,sha256=mmhtcyB7h1aO6jK4njLztPdAebPDmc3mhA7DksR1nlY,17583
20
21
  meta_ads_mcp/core/utils.py,sha256=DsizDYuJnWUpkbShV1y5Qe8t47Qf59aPZ6O9v0hzdkY,6705
21
- meta_ads_mcp-0.3.10.dist-info/METADATA,sha256=FVmGFp-HjIv-9Sev49l29O8Uk69aKVh20wCEAZ1uXlQ,17535
22
- meta_ads_mcp-0.3.10.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
- meta_ads_mcp-0.3.10.dist-info/entry_points.txt,sha256=Dv2RkoBjRJBqj6CyhwqGIiwPCD-SCL1-7B9-zmVRuv0,57
24
- meta_ads_mcp-0.3.10.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
25
- meta_ads_mcp-0.3.10.dist-info/RECORD,,
22
+ meta_ads_mcp-0.4.1.dist-info/METADATA,sha256=XFm8KEWGs0Ej4GQtppFW5kANqnO6URP2FkCJCUHxIH4,17239
23
+ meta_ads_mcp-0.4.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
24
+ meta_ads_mcp-0.4.1.dist-info/entry_points.txt,sha256=Dv2RkoBjRJBqj6CyhwqGIiwPCD-SCL1-7B9-zmVRuv0,57
25
+ meta_ads_mcp-0.4.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
26
+ meta_ads_mcp-0.4.1.dist-info/RECORD,,