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.
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/PKG-INFO +1 -1
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/ads.py +122 -113
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/adsets.py +16 -5
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/pyproject.toml +1 -1
- meta_ads_mcp-0.11.2/tests/test_is_dynamic_creative_adset.py +77 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/.gitignore +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/Dockerfile +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/LICENSE +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/README.md +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/RELEASE.md +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/examples/README.md +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/future_improvements.md +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/accounts.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/auth.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/callback_server.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/duplication.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/openai_deep_research.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/targeting.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/meta_ads_mcp/core/utils.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/requirements.txt +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/setup.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/smithery.yaml +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/README.md +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/e2e_account_info_search_issue.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_account_info_access_fix.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_account_search.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_budget_update.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_budget_update_e2e.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_dsa_beneficiary.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_dsa_integration.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_duplication.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_duplication_regression.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_dynamic_creatives.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_estimate_audience_size.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_estimate_audience_size_e2e.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_get_account_pages.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_get_ad_creatives_fix.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_get_ad_image_quality_improvements.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_get_ad_image_regression.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_insights_actions_and_values.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_integration_openai_mcp.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_mobile_app_adset_creation.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_mobile_app_adset_issue.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_openai.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_openai_mcp_deep_research.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_page_discovery.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_page_discovery_integration.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_targeting.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_targeting_search_e2e.py +0 -0
- {meta_ads_mcp-0.11.0 → meta_ads_mcp-0.11.2}/tests/test_update_ad_creative_id.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
384
|
-
@
|
|
385
|
-
|
|
386
|
-
""
|
|
387
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
453
|
-
|
|
461
|
+
if not image_hashes:
|
|
462
|
+
return json.dumps({"error": "No image hashes found in creative or fallback"}, indent=2)
|
|
454
463
|
|
|
455
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
f
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
506
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|