Skip to content

Commit 200b2b5

Browse files
committed
Fix create_ad_creative to use object_story_spec for simple creatives
Fixes #25 Simple creatives with singular headline/description now correctly use object_story_spec with link_data instead of incorrectly using asset_feed_spec with headlines array which was rejected by Meta API. - Changed create_ad_creative to distinguish between simple and dynamic creatives - Singular headline/description now use object_story_spec->link_data structure - Plural headlines/descriptions still use asset_feed_spec (dynamic creatives) - Updated tests to verify correct behavior - Added E2E test (manual run only)
1 parent 9463fef commit 200b2b5

4 files changed

Lines changed: 257 additions & 89 deletions

File tree

‎meta_ads_mcp/core/ads.py‎

Lines changed: 69 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -831,32 +831,18 @@ async def create_ad_creative(
831831
if description and descriptions:
832832
return json.dumps({"error": "Cannot specify both 'description' and 'descriptions'. Use 'description' for single description or 'descriptions' for multiple."}, indent=2)
833833

834-
# Convert simple parameters to complex format for internal processing
835-
final_headlines = None
836-
final_descriptions = None
837-
838-
if headline:
839-
final_headlines = [headline]
840-
elif headlines:
841-
final_headlines = headlines
842-
843-
if description:
844-
final_descriptions = [description]
845-
elif descriptions:
846-
final_descriptions = descriptions
847-
848-
# Validate dynamic creative parameters
849-
if final_headlines:
850-
if len(final_headlines) > 5:
834+
# Validate dynamic creative parameters (plural forms only)
835+
if headlines:
836+
if len(headlines) > 5:
851837
return json.dumps({"error": "Maximum 5 headlines allowed for dynamic creatives"}, indent=2)
852-
for i, h in enumerate(final_headlines):
838+
for i, h in enumerate(headlines):
853839
if len(h) > 40:
854840
return json.dumps({"error": f"Headline {i+1} exceeds 40 character limit"}, indent=2)
855841

856-
if final_descriptions:
857-
if len(final_descriptions) > 5:
842+
if descriptions:
843+
if len(descriptions) > 5:
858844
return json.dumps({"error": "Maximum 5 descriptions allowed for dynamic creatives"}, indent=2)
859-
for i, d in enumerate(final_descriptions):
845+
for i, d in enumerate(descriptions):
860846
if len(d) > 125:
861847
return json.dumps({"error": f"Description {i+1} exceeds 125 character limit"}, indent=2)
862848

@@ -866,21 +852,22 @@ async def create_ad_creative(
866852
}
867853

868854
# Choose between asset_feed_spec (dynamic creative) or object_story_spec (traditional)
869-
if final_headlines or final_descriptions:
870-
# Use asset_feed_spec for dynamic creatives
855+
# ONLY use asset_feed_spec when user explicitly provides plural parameters (headlines/descriptions)
856+
if headlines or descriptions:
857+
# Use asset_feed_spec for dynamic creatives with multiple variants
871858
asset_feed_spec = {
872859
"ad_formats": ["SINGLE_IMAGE"],
873860
"images": [{"hash": image_hash}],
874861
"link_urls": [{"website_url": link_url if link_url else "https://facebook.com"}]
875862
}
876863

877864
# Handle headlines
878-
if final_headlines:
879-
asset_feed_spec["headlines"] = [{"text": headline_text} for headline_text in final_headlines]
865+
if headlines:
866+
asset_feed_spec["headlines"] = [{"text": headline_text} for headline_text in headlines]
880867

881868
# Handle descriptions
882-
if final_descriptions:
883-
asset_feed_spec["descriptions"] = [{"text": description_text} for description_text in final_descriptions]
869+
if descriptions:
870+
asset_feed_spec["descriptions"] = [{"text": description_text} for description_text in descriptions]
884871

885872
# Add message as primary_texts if provided
886873
if message:
@@ -897,7 +884,7 @@ async def create_ad_creative(
897884
"page_id": page_id
898885
}
899886
else:
900-
# Use traditional object_story_spec for single creative
887+
# Use traditional object_story_spec with link_data for simple creatives
901888
creative_data["object_story_spec"] = {
902889
"page_id": page_id,
903890
"link_data": {
@@ -909,17 +896,25 @@ async def create_ad_creative(
909896
# Add optional parameters if provided
910897
if message:
911898
creative_data["object_story_spec"]["link_data"]["message"] = message
899+
900+
# Add headline (singular) to link_data
901+
if headline:
902+
creative_data["object_story_spec"]["link_data"]["name"] = headline
903+
904+
# Add description (singular) to link_data
905+
if description:
906+
creative_data["object_story_spec"]["link_data"]["description"] = description
907+
908+
# Add call_to_action to link_data for simple creatives
909+
if call_to_action_type:
910+
creative_data["object_story_spec"]["link_data"]["call_to_action"] = {
911+
"type": call_to_action_type
912+
}
912913

913914
# Add dynamic creative spec if provided
914915
if dynamic_creative_spec:
915916
creative_data["dynamic_creative_spec"] = dynamic_creative_spec
916917

917-
# Only add call_to_action to object_story_spec if we're not using asset_feed_spec
918-
if call_to_action_type and "asset_feed_spec" not in creative_data:
919-
creative_data["object_story_spec"]["link_data"]["call_to_action"] = {
920-
"type": call_to_action_type
921-
}
922-
923918
if instagram_actor_id:
924919
creative_data["instagram_actor_id"] = instagram_actor_id
925920

@@ -998,32 +993,18 @@ async def update_ad_creative(
998993
if description and descriptions:
999994
return json.dumps({"error": "Cannot specify both 'description' and 'descriptions'. Use 'description' for single description or 'descriptions' for multiple."}, indent=2)
1000995

1001-
# Convert simple parameters to complex format for internal processing
1002-
final_headlines = None
1003-
final_descriptions = None
1004-
1005-
if headline:
1006-
final_headlines = [headline]
1007-
elif headlines:
1008-
final_headlines = headlines
1009-
1010-
if description:
1011-
final_descriptions = [description]
1012-
elif descriptions:
1013-
final_descriptions = descriptions
1014-
1015-
# Validate dynamic creative parameters
1016-
if final_headlines:
1017-
if len(final_headlines) > 5:
996+
# Validate dynamic creative parameters (plural forms only)
997+
if headlines:
998+
if len(headlines) > 5:
1018999
return json.dumps({"error": "Maximum 5 headlines allowed for dynamic creatives"}, indent=2)
1019-
for i, h in enumerate(final_headlines):
1000+
for i, h in enumerate(headlines):
10201001
if len(h) > 40:
10211002
return json.dumps({"error": f"Headline {i+1} exceeds 40 character limit"}, indent=2)
10221003

1023-
if final_descriptions:
1024-
if len(final_descriptions) > 5:
1004+
if descriptions:
1005+
if len(descriptions) > 5:
10251006
return json.dumps({"error": "Maximum 5 descriptions allowed for dynamic creatives"}, indent=2)
1026-
for i, d in enumerate(final_descriptions):
1007+
for i, d in enumerate(descriptions):
10271008
if len(d) > 125:
10281009
return json.dumps({"error": f"Description {i+1} exceeds 125 character limit"}, indent=2)
10291010

@@ -1033,45 +1014,58 @@ async def update_ad_creative(
10331014
if name:
10341015
update_data["name"] = name
10351016

1036-
if message:
1037-
update_data["object_story_spec"] = {"link_data": {"message": message}}
1038-
1039-
# Handle dynamic creative assets via asset_feed_spec
1040-
if final_headlines or final_descriptions or dynamic_creative_spec:
1017+
# Choose between asset_feed_spec (dynamic creative) or object_story_spec (traditional)
1018+
# ONLY use asset_feed_spec when user explicitly provides plural parameters (headlines/descriptions)
1019+
if headlines or descriptions or dynamic_creative_spec:
1020+
# Handle dynamic creative assets via asset_feed_spec
10411021
asset_feed_spec = {}
10421022

10431023
# Add required ad_formats field for dynamic creatives
10441024
asset_feed_spec["ad_formats"] = ["SINGLE_IMAGE"]
10451025

10461026
# Handle headlines
1047-
if final_headlines:
1048-
asset_feed_spec["headlines"] = [{"text": headline_text} for headline_text in final_headlines]
1027+
if headlines:
1028+
asset_feed_spec["headlines"] = [{"text": headline_text} for headline_text in headlines]
10491029

10501030
# Handle descriptions
1051-
if final_descriptions:
1052-
asset_feed_spec["descriptions"] = [{"text": description_text} for description_text in final_descriptions]
1031+
if descriptions:
1032+
asset_feed_spec["descriptions"] = [{"text": description_text} for description_text in descriptions]
10531033

10541034
# Add message as primary_texts if provided
10551035
if message:
10561036
asset_feed_spec["primary_texts"] = [{"text": message}]
10571037

1038+
# Add call_to_action_types if provided
1039+
if call_to_action_type:
1040+
asset_feed_spec["call_to_action_types"] = [call_to_action_type]
1041+
10581042
update_data["asset_feed_spec"] = asset_feed_spec
1043+
else:
1044+
# Use traditional object_story_spec with link_data for simple creatives
1045+
if message or headline or description or call_to_action_type:
1046+
update_data["object_story_spec"] = {"link_data": {}}
1047+
1048+
if message:
1049+
update_data["object_story_spec"]["link_data"]["message"] = message
1050+
1051+
# Add headline (singular) to link_data
1052+
if headline:
1053+
update_data["object_story_spec"]["link_data"]["name"] = headline
1054+
1055+
# Add description (singular) to link_data
1056+
if description:
1057+
update_data["object_story_spec"]["link_data"]["description"] = description
1058+
1059+
# Add call_to_action to link_data for simple creatives
1060+
if call_to_action_type:
1061+
update_data["object_story_spec"]["link_data"]["call_to_action"] = {
1062+
"type": call_to_action_type
1063+
}
10591064

10601065
# Add dynamic creative spec if provided
10611066
if dynamic_creative_spec:
10621067
update_data["dynamic_creative_spec"] = dynamic_creative_spec
10631068

1064-
# Handle call_to_action - add to asset_feed_spec if using dynamic creative, otherwise to object_story_spec
1065-
if call_to_action_type:
1066-
if "asset_feed_spec" in update_data:
1067-
update_data["asset_feed_spec"]["call_to_action_types"] = [call_to_action_type]
1068-
else:
1069-
if "object_story_spec" not in update_data:
1070-
update_data["object_story_spec"] = {"link_data": {}}
1071-
update_data["object_story_spec"]["link_data"]["call_to_action"] = {
1072-
"type": call_to_action_type
1073-
}
1074-
10751069
# Prepare the API endpoint for updating the creative
10761070
endpoint = f"{creative_id}"
10771071

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Test that create_ad_creative handles simple creatives correctly."""
2+
3+
import pytest
4+
import json
5+
from unittest.mock import AsyncMock, patch
6+
from meta_ads_mcp.core.ads import create_ad_creative
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_simple_creative_uses_object_story_spec():
11+
"""Test that singular headline/description uses object_story_spec, not asset_feed_spec."""
12+
13+
# Mock the make_api_request function
14+
with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api, \
15+
patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover:
16+
17+
# Mock page discovery
18+
mock_discover.return_value = {
19+
"success": True,
20+
"page_id": "123456789",
21+
"page_name": "Test Page"
22+
}
23+
24+
# Mock creative creation response
25+
mock_api.side_effect = [
26+
# First call: Create creative
27+
{"id": "creative_123"},
28+
# Second call: Get creative details
29+
{
30+
"id": "creative_123",
31+
"name": "Test Creative",
32+
"status": "ACTIVE"
33+
}
34+
]
35+
36+
# Call create_ad_creative with singular headline and description
37+
result = await create_ad_creative(
38+
account_id="act_701351919139047",
39+
image_hash="test_hash_123",
40+
name="Math Problem - Hormozi",
41+
link_url="https://adrocketx.ai/",
42+
message="If you're spending 4+ hours per campaign...",
43+
headline="Stop paying yourself $12.50/hour",
44+
description="AI builds campaigns in 3min. 156% higher conversions. Free beta.",
45+
call_to_action_type="LEARN_MORE",
46+
access_token="test_token"
47+
)
48+
49+
# Check that make_api_request was called
50+
assert mock_api.call_count == 2
51+
52+
# Get the creative_data that was sent to the API
53+
create_call_args = mock_api.call_args_list[0]
54+
endpoint = create_call_args[0][0]
55+
creative_data = create_call_args[0][2]
56+
57+
print("Creative data sent to API:")
58+
print(json.dumps(creative_data, indent=2))
59+
60+
# Verify it uses object_story_spec, NOT asset_feed_spec
61+
assert "object_story_spec" in creative_data, "Should use object_story_spec for simple creatives"
62+
assert "asset_feed_spec" not in creative_data, "Should NOT use asset_feed_spec for simple creatives"
63+
64+
# Verify object_story_spec structure
65+
assert "link_data" in creative_data["object_story_spec"]
66+
link_data = creative_data["object_story_spec"]["link_data"]
67+
68+
# Verify simple creative fields are in link_data
69+
assert link_data["image_hash"] == "test_hash_123"
70+
assert link_data["link"] == "https://adrocketx.ai/"
71+
assert link_data["message"] == "If you're spending 4+ hours per campaign..."
72+
73+
# The issue: headline and description should be in link_data for simple creatives
74+
# Not in asset_feed_spec
75+
print("\nlink_data structure:")
76+
print(json.dumps(link_data, indent=2))
77+
78+
79+
@pytest.mark.asyncio
80+
async def test_dynamic_creative_uses_asset_feed_spec():
81+
"""Test that plural headlines/descriptions uses asset_feed_spec."""
82+
83+
with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api, \
84+
patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover:
85+
86+
# Mock page discovery
87+
mock_discover.return_value = {
88+
"success": True,
89+
"page_id": "123456789",
90+
"page_name": "Test Page"
91+
}
92+
93+
# Mock creative creation response
94+
mock_api.side_effect = [
95+
{"id": "creative_456"},
96+
{"id": "creative_456", "name": "Dynamic Creative", "status": "ACTIVE"}
97+
]
98+
99+
# Call with PLURAL headlines and descriptions (dynamic creative)
100+
result = await create_ad_creative(
101+
account_id="act_701351919139047",
102+
image_hash="test_hash_456",
103+
name="Dynamic Creative Test",
104+
link_url="https://example.com/",
105+
message="Test message",
106+
headlines=["Headline 1", "Headline 2"],
107+
descriptions=["Description 1", "Description 2"],
108+
access_token="test_token"
109+
)
110+
111+
# Get the creative_data that was sent to the API
112+
create_call_args = mock_api.call_args_list[0]
113+
creative_data = create_call_args[0][2]
114+
115+
print("\nDynamic creative data sent to API:")
116+
print(json.dumps(creative_data, indent=2))
117+
118+
# Verify it uses asset_feed_spec for dynamic creatives
119+
assert "asset_feed_spec" in creative_data, "Should use asset_feed_spec for dynamic creatives"
120+
121+
# Verify asset_feed_spec structure
122+
asset_feed_spec = creative_data["asset_feed_spec"]
123+
assert "headlines" in asset_feed_spec
124+
assert len(asset_feed_spec["headlines"]) == 2
125+
assert "descriptions" in asset_feed_spec
126+
assert len(asset_feed_spec["descriptions"]) == 2
127+

0 commit comments

Comments
 (0)