meta-ads-mcp 0.11.0__tar.gz → 0.11.2__tar.gz

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.
Files changed (78) hide show
  1. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/PKG-INFO +1 -1
  2. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/__init__.py +1 -1
  3. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/ads.py +122 -113
  4. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/adsets.py +16 -5
  5. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/pyproject.toml +1 -1
  6. meta_ads_mcp-0.11.2/tests/test_is_dynamic_creative_adset.py +77 -0
  7. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/.github/workflows/publish.yml +0 -0
  8. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/.github/workflows/test.yml +0 -0
  9. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/.gitignore +0 -0
  10. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/CUSTOM_META_APP.md +0 -0
  11. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/Dockerfile +0 -0
  12. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/LICENSE +0 -0
  13. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/LOCAL_INSTALLATION.md +0 -0
  14. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/META_API_NOTES.md +0 -0
  15. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/README.md +0 -0
  16. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/RELEASE.md +0 -0
  17. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/STREAMABLE_HTTP_SETUP.md +0 -0
  18. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/examples/README.md +0 -0
  19. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/examples/example_http_client.py +0 -0
  20. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/future_improvements.md +0 -0
  21. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/images/meta-ads-example.png +0 -0
  22. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_auth.sh +0 -0
  23. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/__main__.py +0 -0
  24. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/__init__.py +0 -0
  25. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/accounts.py +0 -0
  26. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/ads_library.py +0 -0
  27. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/api.py +0 -0
  28. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/auth.py +0 -0
  29. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/authentication.py +0 -0
  30. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/budget_schedules.py +0 -0
  31. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/callback_server.py +0 -0
  32. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/campaigns.py +0 -0
  33. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/duplication.py +0 -0
  34. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  35. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/insights.py +0 -0
  36. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/openai_deep_research.py +0 -0
  37. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  38. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/reports.py +0 -0
  39. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/resources.py +0 -0
  40. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/server.py +0 -0
  41. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/targeting.py +0 -0
  42. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/utils.py +0 -0
  43. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/requirements.txt +0 -0
  44. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/setup.py +0 -0
  45. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/smithery.yaml +0 -0
  46. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/README.md +0 -0
  47. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/README_REGRESSION_TESTS.md +0 -0
  48. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/__init__.py +0 -0
  49. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/conftest.py +0 -0
  50. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/e2e_account_info_search_issue.py +0 -0
  51. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_account_info_access_fix.py +0 -0
  52. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_account_search.py +0 -0
  53. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_budget_update.py +0 -0
  54. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_budget_update_e2e.py +0 -0
  55. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_dsa_beneficiary.py +0 -0
  56. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_dsa_integration.py +0 -0
  57. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_duplication.py +0 -0
  58. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_duplication_regression.py +0 -0
  59. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_dynamic_creatives.py +0 -0
  60. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_estimate_audience_size.py +0 -0
  61. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_estimate_audience_size_e2e.py +0 -0
  62. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_get_account_pages.py +0 -0
  63. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_get_ad_creatives_fix.py +0 -0
  64. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_get_ad_image_quality_improvements.py +0 -0
  65. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_get_ad_image_regression.py +0 -0
  66. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_http_transport.py +0 -0
  67. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_insights_actions_and_values.py +0 -0
  68. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_integration_openai_mcp.py +0 -0
  69. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_mobile_app_adset_creation.py +0 -0
  70. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_mobile_app_adset_issue.py +0 -0
  71. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_openai.py +0 -0
  72. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_openai_mcp_deep_research.py +0 -0
  73. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_page_discovery.py +0 -0
  74. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_page_discovery_integration.py +0 -0
  75. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_targeting.py +0 -0
  76. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_targeting_search_e2e.py +0 -0
  77. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_update_ad_creative_id.py +0 -0
  78. {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_upload_ad_image.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-ads-mcp
3
- Version: 0.11.0
3
+ Version: 0.11.2
4
4
  Summary: Model Context Protocol (MCP) server 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
@@ -6,7 +6,7 @@ This package provides a Meta Ads MCP integration
6
6
 
7
7
  from meta_ads_mcp.core.server import main
8
8
 
9
- __version__ = "0.11.0"
9
+ __version__ = "0.11.2"
10
10
 
11
11
  __all__ = [
12
12
  'get_ad_accounts',
@@ -14,6 +14,10 @@ from .utils import download_image, try_multiple_download_methods, ad_creative_im
14
14
  from .server import mcp_server
15
15
 
16
16
 
17
+ # Only register the save_ad_image_locally function if explicitly enabled via environment variable
18
+ ENABLE_SAVE_AD_IMAGE_LOCALLY = bool(os.environ.get("META_ADS_ENABLE_SAVE_AD_IMAGE_LOCALLY", ""))
19
+
20
+
17
21
  @mcp_server.tool()
18
22
  @meta_api_tool
19
23
  async def get_ads(account_id: str, access_token: Optional[str] = None, limit: int = 10,
@@ -107,6 +111,10 @@ async def create_ad(
107
111
  tracking_specs: Optional tracking specifications (e.g., for pixel events).
108
112
  Example: [{"action.type":"offsite_conversion","fb_pixel":["YOUR_PIXEL_ID"]}]
109
113
  access_token: Meta API access token (optional - will use cached token if not provided)
114
+
115
+ Note:
116
+ Dynamic Creative creatives require the parent ad set to have `is_dynamic_creative=true`.
117
+ Otherwise, ad creation will fail with error_subcode 1885998.
110
118
  """
111
119
  # Check required parameters
112
120
  if not account_id:
@@ -380,130 +388,131 @@ async def get_ad_image(ad_id: str, access_token: Optional[str] = None) -> Image:
380
388
  return f"Error processing image: {str(e)}"
381
389
 
382
390
 
383
- @mcp_server.tool()
384
- @meta_api_tool
385
- async def save_ad_image_locally(ad_id: str, access_token: Optional[str] = None, output_dir: str = "ad_images") -> str:
386
- """
387
- Get, download, and save a Meta ad image locally, returning the file path.
388
-
389
- Args:
390
- ad_id: Meta Ads ad ID
391
- access_token: Meta API access token (optional - will use cached token if not provided)
392
- output_dir: Directory to save the image file (default: 'ad_images')
393
-
394
- Returns:
395
- The file path to the saved image, or an error message string.
396
- """
397
- if not ad_id:
398
- return json.dumps({"error": "No ad ID provided"}, indent=2)
391
+ if ENABLE_SAVE_AD_IMAGE_LOCALLY:
392
+ @mcp_server.tool()
393
+ @meta_api_tool
394
+ async def save_ad_image_locally(ad_id: str, access_token: Optional[str] = None, output_dir: str = "ad_images") -> str:
395
+ """
396
+ Get, download, and save a Meta ad image locally, returning the file path.
399
397
 
400
- print(f"Attempting to get and save creative image for ad {ad_id}")
401
-
402
- # First, get creative and account IDs
403
- ad_endpoint = f"{ad_id}"
404
- ad_params = {
405
- "fields": "creative{id},account_id"
406
- }
407
-
408
- ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
409
-
410
- if "error" in ad_data:
411
- return json.dumps({"error": f"Could not get ad data - {json.dumps(ad_data)}"}, indent=2)
412
-
413
- account_id = ad_data.get("account_id")
414
- if not account_id:
415
- return json.dumps({"error": "No account ID found for ad"}, indent=2)
416
-
417
- if "creative" not in ad_data:
418
- return json.dumps({"error": "No creative found for this ad"}, indent=2)
398
+ Args:
399
+ ad_id: Meta Ads ad ID
400
+ access_token: Meta API access token (optional - will use cached token if not provided)
401
+ output_dir: Directory to save the image file (default: 'ad_images')
419
402
 
420
- creative_data = ad_data.get("creative", {})
421
- creative_id = creative_data.get("id")
422
- if not creative_id:
423
- return json.dumps({"error": "No creative ID found"}, indent=2)
424
-
425
- # Get creative details to find image hash
426
- creative_endpoint = f"{creative_id}"
427
- creative_params = {
428
- "fields": "id,name,image_hash,asset_feed_spec"
429
- }
430
- creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
431
-
432
- image_hashes = []
433
- if "image_hash" in creative_details:
434
- image_hashes.append(creative_details["image_hash"])
435
- if "asset_feed_spec" in creative_details and "images" in creative_details["asset_feed_spec"]:
436
- for image in creative_details["asset_feed_spec"]["images"]:
437
- if "hash" in image:
438
- image_hashes.append(image["hash"])
439
-
440
- if not image_hashes:
441
- # Fallback attempt (as in get_ad_image)
442
- creative_json = await get_ad_creatives(ad_id=ad_id, access_token=access_token) # Ensure ad_id is passed correctly
443
- creative_data_list = json.loads(creative_json)
444
- if 'data' in creative_data_list and creative_data_list['data']:
445
- first_creative = creative_data_list['data'][0]
446
- if 'object_story_spec' in first_creative and 'link_data' in first_creative['object_story_spec'] and 'image_hash' in first_creative['object_story_spec']['link_data']:
447
- image_hashes.append(first_creative['object_story_spec']['link_data']['image_hash'])
448
- elif 'image_hash' in first_creative: # Check direct hash on creative data
449
- image_hashes.append(first_creative['image_hash'])
403
+ Returns:
404
+ The file path to the saved image, or an error message string.
405
+ """
406
+ if not ad_id:
407
+ return json.dumps({"error": "No ad ID provided"}, indent=2)
408
+
409
+ print(f"Attempting to get and save creative image for ad {ad_id}")
410
+
411
+ # First, get creative and account IDs
412
+ ad_endpoint = f"{ad_id}"
413
+ ad_params = {
414
+ "fields": "creative{id},account_id"
415
+ }
416
+
417
+ ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
418
+
419
+ if "error" in ad_data:
420
+ return json.dumps({"error": f"Could not get ad data - {json.dumps(ad_data)}"}, indent=2)
421
+
422
+ account_id = ad_data.get("account_id")
423
+ if not account_id:
424
+ return json.dumps({"error": "No account ID found for ad"}, indent=2)
425
+
426
+ if "creative" not in ad_data:
427
+ return json.dumps({"error": "No creative found for this ad"}, indent=2)
428
+
429
+ creative_data = ad_data.get("creative", {})
430
+ creative_id = creative_data.get("id")
431
+ if not creative_id:
432
+ return json.dumps({"error": "No creative ID found"}, indent=2)
433
+
434
+ # Get creative details to find image hash
435
+ creative_endpoint = f"{creative_id}"
436
+ creative_params = {
437
+ "fields": "id,name,image_hash,asset_feed_spec"
438
+ }
439
+ creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
440
+
441
+ image_hashes = []
442
+ if "image_hash" in creative_details:
443
+ image_hashes.append(creative_details["image_hash"])
444
+ if "asset_feed_spec" in creative_details and "images" in creative_details["asset_feed_spec"]:
445
+ for image in creative_details["asset_feed_spec"]["images"]:
446
+ if "hash" in image:
447
+ image_hashes.append(image["hash"])
448
+
449
+ if not image_hashes:
450
+ # Fallback attempt (as in get_ad_image)
451
+ creative_json = await get_ad_creatives(ad_id=ad_id, access_token=access_token) # Ensure ad_id is passed correctly
452
+ creative_data_list = json.loads(creative_json)
453
+ if 'data' in creative_data_list and creative_data_list['data']:
454
+ first_creative = creative_data_list['data'][0]
455
+ if 'object_story_spec' in first_creative and 'link_data' in first_creative['object_story_spec'] and 'image_hash' in first_creative['object_story_spec']['link_data']:
456
+ image_hashes.append(first_creative['object_story_spec']['link_data']['image_hash'])
457
+ elif 'image_hash' in first_creative: # Check direct hash on creative data
458
+ image_hashes.append(first_creative['image_hash'])
450
459
 
451
460
 
452
- if not image_hashes:
453
- return json.dumps({"error": "No image hashes found in creative or fallback"}, indent=2)
461
+ if not image_hashes:
462
+ return json.dumps({"error": "No image hashes found in creative or fallback"}, indent=2)
454
463
 
455
- print(f"Found image hashes: {image_hashes}")
456
-
457
- # Fetch image data using the first hash
458
- image_endpoint = f"act_{account_id}/adimages"
459
- hashes_str = f'["{image_hashes[0]}"]'
460
- image_params = {
461
- "fields": "hash,url,width,height,name,status",
462
- "hashes": hashes_str
463
- }
464
-
465
- print(f"Requesting image data with params: {image_params}")
466
- image_data = await make_api_request(image_endpoint, access_token, image_params)
467
-
468
- if "error" in image_data:
469
- return json.dumps({"error": f"Failed to get image data - {json.dumps(image_data)}"}, indent=2)
470
-
471
- if "data" not in image_data or not image_data["data"]:
472
- return json.dumps({"error": "No image data returned from API"}, indent=2)
464
+ print(f"Found image hashes: {image_hashes}")
473
465
 
474
- first_image = image_data["data"][0]
475
- image_url = first_image.get("url")
476
-
477
- if not image_url:
478
- return json.dumps({"error": "No valid image URL found in API response"}, indent=2)
466
+ # Fetch image data using the first hash
467
+ image_endpoint = f"act_{account_id}/adimages"
468
+ hashes_str = f'["{image_hashes[0]}"]'
469
+ image_params = {
470
+ "fields": "hash,url,width,height,name,status",
471
+ "hashes": hashes_str
472
+ }
479
473
 
480
- print(f"Downloading image from URL: {image_url}")
481
-
482
- # Download and Save Image
483
- image_bytes = await download_image(image_url)
484
-
485
- if not image_bytes:
486
- return json.dumps({"error": "Failed to download image"}, indent=2)
474
+ print(f"Requesting image data with params: {image_params}")
475
+ image_data = await make_api_request(image_endpoint, access_token, image_params)
487
476
 
488
- try:
489
- # Ensure output directory exists
490
- if not os.path.exists(output_dir):
491
- os.makedirs(output_dir)
477
+ if "error" in image_data:
478
+ return json.dumps({"error": f"Failed to get image data - {json.dumps(image_data)}"}, indent=2)
479
+
480
+ if "data" not in image_data or not image_data["data"]:
481
+ return json.dumps({"error": "No image data returned from API"}, indent=2)
482
+
483
+ first_image = image_data["data"][0]
484
+ image_url = first_image.get("url")
485
+
486
+ if not image_url:
487
+ return json.dumps({"error": "No valid image URL found in API response"}, indent=2)
488
+
489
+ print(f"Downloading image from URL: {image_url}")
490
+
491
+ # Download and Save Image
492
+ image_bytes = await download_image(image_url)
493
+
494
+ if not image_bytes:
495
+ return json.dumps({"error": "Failed to download image"}, indent=2)
492
496
 
493
- # Create a filename (e.g., using ad_id and image hash)
494
- file_extension = ".jpg" # Default extension, could try to infer from headers later
495
- filename = f"{ad_id}_{image_hashes[0]}{file_extension}"
496
- filepath = os.path.join(output_dir, filename)
497
-
498
- # Save the image bytes to the file
499
- with open(filepath, "wb") as f:
500
- f.write(image_bytes)
497
+ try:
498
+ # Ensure output directory exists
499
+ if not os.path.exists(output_dir):
500
+ os.makedirs(output_dir)
501
+
502
+ # Create a filename (e.g., using ad_id and image hash)
503
+ file_extension = ".jpg" # Default extension, could try to infer from headers later
504
+ filename = f"{ad_id}_{image_hashes[0]}{file_extension}"
505
+ filepath = os.path.join(output_dir, filename)
501
506
 
502
- print(f"Image saved successfully to: {filepath}")
503
- return json.dumps({"filepath": filepath}, indent=2) # Return JSON with filepath
507
+ # Save the image bytes to the file
508
+ with open(filepath, "wb") as f:
509
+ f.write(image_bytes)
510
+
511
+ print(f"Image saved successfully to: {filepath}")
512
+ return json.dumps({"filepath": filepath}, indent=2) # Return JSON with filepath
504
513
 
505
- except Exception as e:
506
- return json.dumps({"error": f"Failed to save image: {str(e)}"}, indent=2)
514
+ except Exception as e:
515
+ return json.dumps({"error": f"Failed to save image: {str(e)}"}, indent=2)
507
516
 
508
517
 
509
518
  @mcp_server.tool()
@@ -27,14 +27,14 @@ async def get_adsets(account_id: str, access_token: Optional[str] = None, limit:
27
27
  if campaign_id:
28
28
  endpoint = f"{campaign_id}/adsets"
29
29
  params = {
30
- "fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,frequency_control_specs{event,interval_days,max_frequency}",
30
+ "fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,is_dynamic_creative,frequency_control_specs{event,interval_days,max_frequency}",
31
31
  "limit": limit
32
32
  }
33
33
  else:
34
34
  # Use account endpoint if no campaign_id is given
35
35
  endpoint = f"{account_id}/adsets"
36
36
  params = {
37
- "fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,frequency_control_specs{event,interval_days,max_frequency}",
37
+ "fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,is_dynamic_creative,frequency_control_specs{event,interval_days,max_frequency}",
38
38
  "limit": limit
39
39
  }
40
40
  # Note: Removed the attempt to add campaign_id to params for the account endpoint case,
@@ -67,7 +67,7 @@ async def get_adset_details(adset_id: str, access_token: Optional[str] = None) -
67
67
  endpoint = f"{adset_id}"
68
68
  # Explicitly prioritize frequency_control_specs in the fields request
69
69
  params = {
70
- "fields": "id,name,campaign_id,status,frequency_control_specs{event,interval_days,max_frequency},daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,attribution_spec,destination_type,promoted_object,pacing_type,budget_remaining,dsa_beneficiary"
70
+ "fields": "id,name,campaign_id,status,frequency_control_specs{event,interval_days,max_frequency},daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,attribution_spec,destination_type,promoted_object,pacing_type,budget_remaining,dsa_beneficiary,is_dynamic_creative"
71
71
  }
72
72
 
73
73
  data = await make_api_request(endpoint, access_token, params)
@@ -100,6 +100,7 @@ async def create_adset(
100
100
  dsa_beneficiary: Optional[str] = None,
101
101
  promoted_object: Optional[Dict[str, Any]] = None,
102
102
  destination_type: Optional[str] = None,
103
+ is_dynamic_creative: Optional[bool] = None,
103
104
  access_token: Optional[str] = None
104
105
  ) -> str:
105
106
  """
@@ -125,8 +126,9 @@ async def create_adset(
125
126
  Optional fields: custom_event_type, pixel_id, page_id.
126
127
  Example: {"application_id": "123456789012345", "object_store_url": "https://apps.apple.com/app/id123456789"}
127
128
  destination_type: Where users are directed after clicking the ad (e.g., 'APP_STORE', 'DEEPLINK', 'APP_INSTALL', 'ON_AD').
128
- Required for mobile app campaigns and lead generation campaigns.
129
- Use 'ON_AD' for lead generation campaigns where user interaction happens within the ad.
129
+ Required for mobile app campaigns and lead generation campaigns.
130
+ Use 'ON_AD' for lead generation campaigns where user interaction happens within the ad.
131
+ is_dynamic_creative: Enable Dynamic Creative for this ad set (required when using dynamic creatives with asset_feed_spec/dynamic_creative_spec).
130
132
  access_token: Meta API access token (optional - will use cached token if not provided)
131
133
  """
132
134
  # Check required parameters
@@ -249,6 +251,10 @@ async def create_adset(
249
251
  if destination_type:
250
252
  params["destination_type"] = destination_type
251
253
 
254
+ # Enable Dynamic Creative if requested
255
+ if is_dynamic_creative is not None:
256
+ params["is_dynamic_creative"] = "true" if bool(is_dynamic_creative) else "false"
257
+
252
258
  try:
253
259
  data = await make_api_request(endpoint, access_token, params, method="POST")
254
260
  return json.dumps(data, indent=2)
@@ -290,6 +296,7 @@ async def create_adset(
290
296
  async def update_adset(adset_id: str, frequency_control_specs: Optional[List[Dict[str, Any]]] = None, bid_strategy: Optional[str] = None,
291
297
  bid_amount: Optional[int] = None, status: Optional[str] = None, targeting: Optional[Dict[str, Any]] = None,
292
298
  optimization_goal: Optional[str] = None, daily_budget: Optional[int] = None, lifetime_budget: Optional[int] = None,
299
+ is_dynamic_creative: Optional[bool] = None,
293
300
  access_token: Optional[str] = None) -> str:
294
301
  """
295
302
  Update an ad set with new settings including frequency caps and budgets.
@@ -306,6 +313,7 @@ async def update_adset(adset_id: str, frequency_control_specs: Optional[List[Dic
306
313
  optimization_goal: Conversion optimization goal (e.g., 'LINK_CLICKS', 'CONVERSIONS', 'APP_INSTALLS', etc.)
307
314
  daily_budget: Daily budget in account currency (in cents) as a string
308
315
  lifetime_budget: Lifetime budget in account currency (in cents) as a string
316
+ is_dynamic_creative: Enable/disable Dynamic Creative for this ad set.
309
317
  access_token: Meta API access token (optional - will use cached token if not provided)
310
318
  """
311
319
  if not adset_id:
@@ -342,6 +350,9 @@ async def update_adset(adset_id: str, frequency_control_specs: Optional[List[Dic
342
350
  if lifetime_budget is not None:
343
351
  params['lifetime_budget'] = str(lifetime_budget)
344
352
 
353
+ if is_dynamic_creative is not None:
354
+ params['is_dynamic_creative'] = "true" if bool(is_dynamic_creative) else "false"
355
+
345
356
  if not params:
346
357
  return json.dumps({"error": "No update parameters provided"}, indent=2)
347
358
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "0.11.0"
7
+ version = "0.11.2"
8
8
  description = "Model Context Protocol (MCP) server for interacting with Meta Ads API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,77 @@
1
+ import json
2
+ import pytest
3
+ from unittest.mock import AsyncMock, patch
4
+
5
+ from meta_ads_mcp.core.adsets import create_adset, update_adset, get_adsets, get_adset_details
6
+
7
+
8
+ @pytest.mark.asyncio
9
+ async def test_create_adset_includes_is_dynamic_creative_true():
10
+ sample_response = {"id": "adset_1", "name": "DC Adset"}
11
+ with patch('meta_ads_mcp.core.adsets.make_api_request', new_callable=AsyncMock) as mock_api:
12
+ mock_api.return_value = sample_response
13
+
14
+ result = await create_adset(
15
+ account_id="act_123",
16
+ campaign_id="cmp_1",
17
+ name="DC Adset",
18
+ optimization_goal="LINK_CLICKS",
19
+ billing_event="IMPRESSIONS",
20
+ targeting={"geo_locations": {"countries": ["US"]}},
21
+ is_dynamic_creative=True,
22
+ access_token="test_token",
23
+ )
24
+
25
+ assert json.loads(result)["id"] == "adset_1"
26
+ # Verify param was sent as string boolean
27
+ call_args = mock_api.call_args
28
+ params = call_args[0][2]
29
+ assert params["is_dynamic_creative"] == "true"
30
+
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_update_adset_includes_is_dynamic_creative_false():
34
+ sample_response = {"success": True}
35
+ with patch('meta_ads_mcp.core.adsets.make_api_request', new_callable=AsyncMock) as mock_api:
36
+ mock_api.return_value = sample_response
37
+
38
+ result = await update_adset(
39
+ adset_id="120",
40
+ is_dynamic_creative=False,
41
+ access_token="test_token",
42
+ )
43
+
44
+ assert json.loads(result)["success"] is True
45
+ call_args = mock_api.call_args
46
+ params = call_args[0][2]
47
+ assert params["is_dynamic_creative"] == "false"
48
+
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_get_adsets_fields_include_is_dynamic_creative():
52
+ sample_response = {"data": []}
53
+ with patch('meta_ads_mcp.core.adsets.make_api_request', new_callable=AsyncMock) as mock_api:
54
+ mock_api.return_value = sample_response
55
+
56
+ result = await get_adsets(account_id="act_123", access_token="test_token", limit=1)
57
+ assert json.loads(result)["data"] == []
58
+
59
+ call_args = mock_api.call_args
60
+ params = call_args[0][2]
61
+ assert "is_dynamic_creative" in params.get("fields", "")
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_get_adset_details_fields_include_is_dynamic_creative():
66
+ sample_response = {"id": "120", "name": "Test", "is_dynamic_creative": True}
67
+ with patch('meta_ads_mcp.core.adsets.make_api_request', new_callable=AsyncMock) as mock_api:
68
+ mock_api.return_value = sample_response
69
+
70
+ result = await get_adset_details(adset_id="120", access_token="test_token")
71
+ assert json.loads(result)["id"] == "120"
72
+
73
+ call_args = mock_api.call_args
74
+ params = call_args[0][2]
75
+ assert "is_dynamic_creative" in params.get("fields", "")
76
+
77
+
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes