Skip to content

Commit 57ff475

Browse files
committed
feat: add get_creative_details tool for creative read-back verification
- New MCP tool that fetches creative details by ID (fields: id, name, status, thumbnail_url, image_url, object_story_spec, asset_feed_spec, etc.) - Uses two-phase fetch: safe fields first, then dynamic_creative_spec separately with graceful fallback (Meta API v24 rejects it on simple creatives) - Exported from core/__init__.py - Unit tests covering happy path, missing dynamic_creative_spec, empty ID, API errors
1 parent 0473997 commit 57ff475

File tree

3 files changed

+157
-1
lines changed

3 files changed

+157
-1
lines changed

meta_ads_mcp/core/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from .accounts import get_ad_accounts, get_account_info
55
from .campaigns import get_campaigns, get_campaign_details, create_campaign
66
from .adsets import get_adsets, get_adset_details, update_adset
7-
from .ads import get_ads, get_ad_details, get_ad_creatives, get_ad_image, update_ad
7+
from .ads import get_ads, get_ad_details, get_creative_details, get_ad_creatives, get_ad_image, update_ad
88
from .insights import get_insights
99
from . import authentication # Import module to register conditional auth tools
1010
from .server import login_cli, main
@@ -28,6 +28,7 @@
2828
'update_adset',
2929
'get_ads',
3030
'get_ad_details',
31+
'get_creative_details',
3132
'get_ad_creatives',
3233
'get_ad_image',
3334
'update_ad',

meta_ads_mcp/core/ads.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,40 @@ async def get_ad_details(ad_id: str, access_token: Optional[str] = None) -> str:
8989
return json.dumps(data, indent=2)
9090

9191

92+
@mcp_server.tool()
93+
@meta_api_tool
94+
async def get_creative_details(creative_id: str, access_token: Optional[str] = None) -> str:
95+
"""Get detailed information about a specific ad creative by its ID.
96+
97+
Args:
98+
creative_id: Meta Ads creative ID (required)
99+
access_token: Meta API access token (optional)
100+
"""
101+
if not creative_id:
102+
return json.dumps({"error": "No creative ID provided"}, indent=2)
103+
endpoint = f"{creative_id}"
104+
# Note: dynamic_creative_spec is only valid on dynamic creatives and causes
105+
# "(#100) Tried accessing nonexisting field" on simple creatives in API v24.
106+
# We fetch the safe fields first, then try dynamic_creative_spec separately.
107+
params = {
108+
"fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,asset_feed_spec,url_tags,link_url"
109+
}
110+
data = await make_api_request(endpoint, access_token, params)
111+
112+
# Try to fetch dynamic_creative_spec separately (only exists on dynamic creatives)
113+
if isinstance(data, dict) and "id" in data:
114+
try:
115+
dcs_data = await make_api_request(
116+
endpoint, access_token, {"fields": "dynamic_creative_spec"}
117+
)
118+
if isinstance(dcs_data, dict) and "dynamic_creative_spec" in dcs_data:
119+
data["dynamic_creative_spec"] = dcs_data["dynamic_creative_spec"]
120+
except Exception:
121+
pass # Field doesn't exist on this creative type
122+
123+
return json.dumps(data, indent=2)
124+
125+
92126
@mcp_server.tool()
93127
@meta_api_tool
94128
async def create_ad(

tests/test_get_creative_details.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Test get_creative_details tool."""
2+
3+
import pytest
4+
import json
5+
from unittest.mock import patch
6+
from meta_ads_mcp.core.ads import get_creative_details
7+
8+
9+
def parse_result(result: str) -> dict:
10+
"""Parse result, unwrapping the meta_api_tool decorator envelope if present."""
11+
data = json.loads(result)
12+
if "data" in data and isinstance(data["data"], str):
13+
return json.loads(data["data"])
14+
return data
15+
16+
17+
@pytest.mark.asyncio
18+
async def test_get_creative_details_returns_fields():
19+
"""Test that get_creative_details returns creative fields from the API."""
20+
mock_main_response = {
21+
"id": "creative_123",
22+
"name": "Test Creative",
23+
"status": "ACTIVE",
24+
"thumbnail_url": "https://example.com/thumb.jpg",
25+
"image_url": "https://example.com/image.jpg",
26+
"object_story_spec": {
27+
"page_id": "page_456",
28+
"video_data": {
29+
"video_id": "vid_789",
30+
"message": "Test message",
31+
},
32+
},
33+
"asset_feed_spec": {
34+
"bodies": [{"text": "Body A"}],
35+
"optimization_type": "DEGREES_OF_FREEDOM",
36+
},
37+
}
38+
mock_dcs_response = {
39+
"dynamic_creative_spec": {"some_field": "some_value"},
40+
}
41+
42+
with patch("meta_ads_mcp.core.ads.make_api_request") as mock_api:
43+
mock_api.side_effect = [mock_main_response, mock_dcs_response]
44+
45+
result = await get_creative_details(
46+
creative_id="creative_123", access_token="test_token"
47+
)
48+
49+
data = parse_result(result)
50+
assert data["id"] == "creative_123"
51+
assert data["name"] == "Test Creative"
52+
assert data["status"] == "ACTIVE"
53+
assert data["object_story_spec"]["video_data"]["video_id"] == "vid_789"
54+
assert data["asset_feed_spec"]["optimization_type"] == "DEGREES_OF_FREEDOM"
55+
assert data["dynamic_creative_spec"] == {"some_field": "some_value"}
56+
57+
# Verify the API was called twice: main fields + dynamic_creative_spec
58+
assert mock_api.call_count == 2
59+
# First call: main fields (should NOT include dynamic_creative_spec)
60+
first_call = mock_api.call_args_list[0]
61+
assert first_call[0][0] == "creative_123"
62+
assert "object_story_spec" in first_call[0][2]["fields"]
63+
assert "asset_feed_spec" in first_call[0][2]["fields"]
64+
assert "dynamic_creative_spec" not in first_call[0][2]["fields"]
65+
# Second call: dynamic_creative_spec only
66+
second_call = mock_api.call_args_list[1]
67+
assert second_call[0][2]["fields"] == "dynamic_creative_spec"
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_get_creative_details_without_dynamic_creative_spec():
72+
"""Test that get_creative_details works when dynamic_creative_spec is not available."""
73+
mock_main_response = {
74+
"id": "creative_456",
75+
"name": "Simple Creative",
76+
"status": "ACTIVE",
77+
"object_story_spec": {
78+
"page_id": "page_789",
79+
"video_data": {"video_id": "vid_111"},
80+
},
81+
}
82+
# Second call fails (field doesn't exist on this creative type)
83+
mock_dcs_error = {
84+
"error": {"message": "Tried accessing nonexisting field", "code": 100}
85+
}
86+
87+
with patch("meta_ads_mcp.core.ads.make_api_request") as mock_api:
88+
mock_api.side_effect = [mock_main_response, mock_dcs_error]
89+
90+
result = await get_creative_details(
91+
creative_id="creative_456", access_token="test_token"
92+
)
93+
94+
data = parse_result(result)
95+
assert data["id"] == "creative_456"
96+
assert "dynamic_creative_spec" not in data
97+
98+
99+
@pytest.mark.asyncio
100+
async def test_get_creative_details_empty_id():
101+
"""Test that empty creative_id returns an error."""
102+
result = await get_creative_details(creative_id="", access_token="test_token")
103+
data = parse_result(result)
104+
assert "error" in data
105+
assert "No creative ID" in data["error"]
106+
107+
108+
@pytest.mark.asyncio
109+
async def test_get_creative_details_api_error():
110+
"""Test that API errors are propagated."""
111+
with patch("meta_ads_mcp.core.ads.make_api_request") as mock_api:
112+
mock_api.return_value = {
113+
"error": {"message": "Invalid creative ID", "code": 100}
114+
}
115+
116+
result = await get_creative_details(
117+
creative_id="bad_id", access_token="test_token"
118+
)
119+
120+
data = parse_result(result)
121+
assert "error" in data

0 commit comments

Comments
 (0)