meta-ads-mcp 0.2.1__tar.gz → 0.2.3__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 (29) hide show
  1. {meta_ads_mcp-0.2.1 → meta_ads_mcp-0.2.3}/.gitignore +10 -0
  2. {meta_ads_mcp-0.2.1 → meta_ads_mcp-0.2.3}/PKG-INFO +1 -1
  3. meta_ads_mcp-0.2.3/future_improvements.md +59 -0
  4. meta_ads_mcp-0.2.3/meta-ads-mcp +14 -0
  5. {meta_ads_mcp-0.2.1 → meta_ads_mcp-0.2.3}/meta_ads_mcp/__init__.py +19 -5
  6. meta_ads_mcp-0.2.3/meta_ads_mcp/__main__.py +10 -0
  7. meta_ads_mcp-0.2.3/meta_ads_mcp/core/__init__.py +34 -0
  8. meta_ads_mcp-0.2.3/meta_ads_mcp/core/accounts.py +59 -0
  9. meta_ads_mcp-0.2.3/meta_ads_mcp/core/ads.py +361 -0
  10. meta_ads_mcp-0.2.3/meta_ads_mcp/core/adsets.py +115 -0
  11. meta_ads_mcp-0.2.3/meta_ads_mcp/core/api.py +211 -0
  12. meta_ads_mcp-0.2.3/meta_ads_mcp/core/auth.py +416 -0
  13. meta_ads_mcp-0.2.3/meta_ads_mcp/core/authentication.py +56 -0
  14. meta_ads_mcp-0.2.3/meta_ads_mcp/core/campaigns.py +119 -0
  15. meta_ads_mcp-0.2.3/meta_ads_mcp/core/insights.py +412 -0
  16. meta_ads_mcp-0.2.3/meta_ads_mcp/core/resources.py +46 -0
  17. meta_ads_mcp-0.2.3/meta_ads_mcp/core/server.py +56 -0
  18. meta_ads_mcp-0.2.3/meta_ads_mcp/core/utils.py +128 -0
  19. {meta_ads_mcp-0.2.1 → meta_ads_mcp-0.2.3}/pyproject.toml +1 -1
  20. {meta_ads_mcp-0.2.1 → meta_ads_mcp-0.2.3}/test_meta_ads_auth.py +2 -1
  21. meta_ads_mcp-0.2.1/.python-version +0 -1
  22. meta_ads_mcp-0.2.1/.uv.toml +0 -2
  23. meta_ads_mcp-0.2.1/meta_ads_generated.py +0 -1840
  24. meta_ads_mcp-0.2.1/poetry.lock +0 -437
  25. meta_ads_mcp-0.2.1/uv.lock +0 -462
  26. {meta_ads_mcp-0.2.1 → meta_ads_mcp-0.2.3}/README.md +0 -0
  27. {meta_ads_mcp-0.2.1 → meta_ads_mcp-0.2.3}/meta_ads_mcp/api.py +0 -0
  28. {meta_ads_mcp-0.2.1 → meta_ads_mcp-0.2.3}/requirements.txt +0 -0
  29. {meta_ads_mcp-0.2.1 → meta_ads_mcp-0.2.3}/setup.py +0 -0
@@ -10,3 +10,13 @@ wheels/
10
10
  .venv
11
11
  .cursor/rules/meta-ads-credentials.mdc
12
12
  .DS_Store
13
+
14
+ # Development environment files
15
+ .python-version
16
+ poetry.lock
17
+ uv.lock
18
+ .uv.toml
19
+ .cursor/
20
+
21
+ # Generated content
22
+ ad_creatives/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-ads-mcp
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Model Calling Protocol (MCP) plugin for interacting with Meta Ads API
5
5
  Project-URL: Homepage, https://github.com/nictuku/meta-ads-mcp
6
6
  Project-URL: Bug Tracker, https://github.com/nictuku/meta-ads-mcp/issues
@@ -0,0 +1,59 @@
1
+ # Future Improvements for Meta Ads MCP
2
+
3
+ ## Planned Improvement: Refactored meta_ads_generated.py
4
+
5
+ This file currently has too much code and it needs to be split into a small number of modules.
6
+
7
+ ## Planned Improvement: Ad Set Update Confirmation Flow
8
+
9
+ ### Overview
10
+ Currently, the `update_adset` function directly applies changes to Meta Ad Sets without user confirmation. This is potentially risky as users may not fully understand the impact of their changes before they're applied. We plan to implement a confirmation flow similar to the authentication flow that already exists for the `get_login_link` function.
11
+
12
+ ### Requirements
13
+
14
+ 1. **Local Confirmation Server:**
15
+ - Leverage the existing local HTTP server infrastructure used for authentication
16
+ - Create a new endpoint at `/confirm-update` to display proposed changes
17
+ - Ensure the server can handle multiple types of confirmation flows
18
+
19
+ 2. **Update Flow:**
20
+ - When `update_adset` is called, it will:
21
+ - Fetch the current ad set details to establish a baseline
22
+ - Generate a diff between current and proposed changes
23
+ - Return a clickable link to a local confirmation page instead of making immediate changes
24
+
25
+ 3. **Confirmation UI:**
26
+ - Display ad set name and ID clearly at the top
27
+ - Show a side-by-side or diff view of the current vs. proposed settings
28
+ - Highlight specific changes (e.g., bid amount, bid strategy, status changes)
29
+ - Provide "Approve" and "Cancel" buttons
30
+ - Include a warning about potential billing impact for status changes
31
+
32
+ 4. **Security Considerations:**
33
+ - Ensure that confirmation links have a short expiration time
34
+ - Generate unique tokens for each confirmation request to prevent replays
35
+ - Log all confirmation activities
36
+
37
+ 5. **Response Handling:**
38
+ - If approved, execute the original update API call
39
+ - Return a success message with details of the changes applied
40
+ - If rejected, return a cancellation message
41
+ - Either way, provide detailed information to the user
42
+
43
+ 6. **Implementation Timeline:**
44
+ - Phase 1: Implement the basic confirmation flow structure
45
+ - Phase 2: Add improved diff visualization
46
+ - Phase 3: Extend to other modification functions (campaigns, ads, etc.)
47
+
48
+ ### Implementation Notes
49
+
50
+ The implementation will reuse much of the callback server infrastructure that already exists for authentication, adapting it to support this new use case. The confirmation page will be a simple HTML/JS application that can:
51
+
52
+ 1. Parse URL parameters containing update details
53
+ 2. Fetch current ad set state using the provided access token
54
+ 3. Generate and display a visual diff
55
+ 4. Send the confirmation back to the server via API call
56
+
57
+ By ensuring changes are confirmed before execution, we'll significantly reduce the risk of accidental modifications to ad campaigns while maintaining the flexibility and power of the Meta Ads MCP.
58
+
59
+ Future improvements can be added to this file as needed.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ Meta Ads MCP - Command Line Entry Point
6
+
7
+ This script provides the command-line entry point for the Meta Ads MCP package.
8
+ """
9
+
10
+ from meta_ads_mcp import main
11
+
12
+ if __name__ == "__main__":
13
+ print("RUNNING LOCAL SCRIPT VERSION OF META ADS MCP")
14
+ main()
@@ -1,9 +1,18 @@
1
- """Meta Ads MCP - Model Calling Protocol plugin for Meta Ads API."""
1
+ """
2
+ Meta Ads MCP - Python Package
2
3
 
3
- __version__ = "0.2.1"
4
+ This package provides a Meta Ads Marketing Cloud Platform (MCP) integration
5
+ with the Claude LLM.
6
+ """
7
+
8
+ from meta_ads_mcp.core.server import main
9
+
10
+ __version__ = "0.2.3"
11
+
12
+ __all__ = ["main"]
4
13
 
5
14
  # Import key functions to make them available at package level
6
- from .api import (
15
+ from .core import (
7
16
  get_ad_accounts,
8
17
  get_account_info,
9
18
  get_campaigns,
@@ -11,17 +20,22 @@ from .api import (
11
20
  create_campaign,
12
21
  get_adsets,
13
22
  get_adset_details,
23
+ update_adset,
14
24
  get_ads,
15
25
  get_ad_details,
16
26
  get_ad_creatives,
17
27
  get_ad_image,
18
28
  get_insights,
29
+ debug_image_download,
30
+ save_ad_image_via_api,
19
31
  get_login_link,
20
32
  login_cli,
21
- main, # Import main function for entry point
22
33
  )
23
34
 
24
35
  # Define a main function to be used as a package entry point
25
36
  def entrypoint():
26
37
  """Main entry point for the package when invoked with uvx."""
27
- return main()
38
+ return main()
39
+
40
+ # Re-export main for direct access
41
+ main = main
@@ -0,0 +1,10 @@
1
+ """
2
+ Meta Ads MCP - Main Entry Point
3
+
4
+ This module allows the package to be executed directly via `python -m meta_ads_mcp`
5
+ """
6
+
7
+ from meta_ads_mcp.core.server import main
8
+
9
+ if __name__ == "__main__":
10
+ main()
@@ -0,0 +1,34 @@
1
+ """Core functionality for Meta Ads API MCP package."""
2
+
3
+ from .server import mcp_server
4
+ from .accounts import get_ad_accounts, get_account_info
5
+ from .campaigns import get_campaigns, get_campaign_details, create_campaign
6
+ 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
8
+ from .insights import get_insights, debug_image_download, save_ad_image_via_api
9
+ from .authentication import get_login_link
10
+ from .server import login_cli, main
11
+ from .auth import login
12
+
13
+ __all__ = [
14
+ 'mcp_server',
15
+ 'get_ad_accounts',
16
+ 'get_account_info',
17
+ 'get_campaigns',
18
+ 'get_campaign_details',
19
+ 'create_campaign',
20
+ 'get_adsets',
21
+ 'get_adset_details',
22
+ 'update_adset',
23
+ 'get_ads',
24
+ 'get_ad_details',
25
+ 'get_ad_creatives',
26
+ 'get_ad_image',
27
+ 'get_insights',
28
+ 'debug_image_download',
29
+ 'save_ad_image_via_api',
30
+ 'get_login_link',
31
+ 'login_cli',
32
+ 'login',
33
+ 'main',
34
+ ]
@@ -0,0 +1,59 @@
1
+ """Account-related functionality for Meta Ads API."""
2
+
3
+ import json
4
+ from typing import Optional
5
+ from .api import meta_api_tool, make_api_request
6
+
7
+
8
+ @meta_api_tool
9
+ async def get_ad_accounts(access_token: str = None, user_id: str = "me", limit: int = 10) -> str:
10
+ """
11
+ Get ad accounts accessible by a user.
12
+
13
+ Args:
14
+ access_token: Meta API access token (optional - will use cached token if not provided)
15
+ user_id: Meta user ID or "me" for the current user
16
+ limit: Maximum number of accounts to return (default: 10)
17
+ """
18
+ endpoint = f"{user_id}/adaccounts"
19
+ params = {
20
+ "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code",
21
+ "limit": limit
22
+ }
23
+
24
+ data = await make_api_request(endpoint, access_token, params)
25
+
26
+ return json.dumps(data, indent=2)
27
+
28
+
29
+ @meta_api_tool
30
+ async def get_account_info(access_token: str = None, account_id: str = None) -> str:
31
+ """
32
+ Get detailed information about a specific ad account.
33
+
34
+ Args:
35
+ access_token: Meta API access token (optional - will use cached token if not provided)
36
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX)
37
+ """
38
+ # If no account ID is specified, try to get the first one for the user
39
+ if not account_id:
40
+ accounts_json = await get_ad_accounts("me", json.dumps({"limit": 1}), access_token)
41
+ accounts_data = json.loads(accounts_json)
42
+
43
+ if "data" in accounts_data and accounts_data["data"]:
44
+ account_id = accounts_data["data"][0]["id"]
45
+ else:
46
+ return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
47
+
48
+ # Ensure account_id has the 'act_' prefix for API compatibility
49
+ if not account_id.startswith("act_"):
50
+ account_id = f"act_{account_id}"
51
+
52
+ endpoint = f"{account_id}"
53
+ params = {
54
+ "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,funding_source_details,business_city,business_country_code,timezone_name,owner"
55
+ }
56
+
57
+ data = await make_api_request(endpoint, access_token, params)
58
+
59
+ return json.dumps(data, indent=2)
@@ -0,0 +1,361 @@
1
+ """Ad and Creative-related functionality for Meta Ads API."""
2
+
3
+ import json
4
+ from typing import Optional, Dict, Any
5
+ import io
6
+ from PIL import Image as PILImage
7
+ from mcp.server.fastmcp import Image
8
+
9
+ from .api import meta_api_tool, make_api_request
10
+ from .accounts import get_ad_accounts
11
+ from .utils import download_image, try_multiple_download_methods, ad_creative_images
12
+
13
+
14
+ @meta_api_tool
15
+ async def get_ads(access_token: str = None, account_id: str = None, limit: int = 10,
16
+ campaign_id: str = "", adset_id: str = "") -> str:
17
+ """
18
+ Get ads for a Meta Ads account with optional filtering.
19
+
20
+ Args:
21
+ access_token: Meta API access token (optional - will use cached token if not provided)
22
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX)
23
+ limit: Maximum number of ads to return (default: 10)
24
+ campaign_id: Optional campaign ID to filter by
25
+ adset_id: Optional ad set ID to filter by
26
+ """
27
+ # If no account ID is specified, try to get the first one for the user
28
+ if not account_id:
29
+ accounts_json = await get_ad_accounts("me", json.dumps({"limit": 1}), access_token)
30
+ accounts_data = json.loads(accounts_json)
31
+
32
+ if "data" in accounts_data and accounts_data["data"]:
33
+ account_id = accounts_data["data"][0]["id"]
34
+ else:
35
+ return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
36
+
37
+ endpoint = f"{account_id}/ads"
38
+ params = {
39
+ "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
40
+ "limit": limit
41
+ }
42
+
43
+ if campaign_id:
44
+ params["campaign_id"] = campaign_id
45
+
46
+ if adset_id:
47
+ params["adset_id"] = adset_id
48
+
49
+ data = await make_api_request(endpoint, access_token, params)
50
+
51
+ return json.dumps(data, indent=2)
52
+
53
+
54
+ @meta_api_tool
55
+ async def get_ad_details(access_token: str = None, ad_id: str = None) -> str:
56
+ """
57
+ Get detailed information about a specific ad.
58
+
59
+ Args:
60
+ access_token: Meta API access token (optional - will use cached token if not provided)
61
+ ad_id: Meta Ads ad ID
62
+ """
63
+ if not ad_id:
64
+ return json.dumps({"error": "No ad ID provided"}, indent=2)
65
+
66
+ endpoint = f"{ad_id}"
67
+ params = {
68
+ "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs,preview_shareable_link"
69
+ }
70
+
71
+ data = await make_api_request(endpoint, access_token, params)
72
+
73
+ return json.dumps(data, indent=2)
74
+
75
+
76
+ @meta_api_tool
77
+ async def get_ad_creatives(access_token: str = None, ad_id: str = None) -> str:
78
+ """
79
+ Get creative details for a specific ad. Best if combined with get_ad_image to get the full image.
80
+
81
+ Args:
82
+ access_token: Meta API access token (optional - will use cached token if not provided)
83
+ ad_id: Meta Ads ad ID
84
+ """
85
+ if not ad_id:
86
+ return json.dumps({"error": "No ad ID provided"}, indent=2)
87
+
88
+ # First, get the creative ID from the ad
89
+ endpoint = f"{ad_id}"
90
+ params = {
91
+ "fields": "creative"
92
+ }
93
+
94
+ ad_data = await make_api_request(endpoint, access_token, params)
95
+
96
+ if "error" in ad_data:
97
+ return json.dumps(ad_data, indent=2)
98
+
99
+ if "creative" not in ad_data:
100
+ return json.dumps({"error": "No creative found for this ad"}, indent=2)
101
+
102
+ creative_id = ad_data.get("creative", {}).get("id")
103
+ if not creative_id:
104
+ return json.dumps({"error": "Creative ID not found", "ad_data": ad_data}, indent=2)
105
+
106
+ # Now get the creative details with essential fields
107
+ creative_endpoint = f"{creative_id}"
108
+ creative_params = {
109
+ "fields": "id,name,title,body,image_url,object_story_spec,url_tags,link_url,thumbnail_url,image_hash,asset_feed_spec,object_type"
110
+ }
111
+
112
+ creative_data = await make_api_request(creative_endpoint, access_token, creative_params)
113
+
114
+ # Try to get full-size images in different ways:
115
+
116
+ # 1. First approach: Get ad images directly using the adimages endpoint
117
+ if "image_hash" in creative_data:
118
+ image_hash = creative_data.get("image_hash")
119
+ image_endpoint = f"act_{ad_data.get('account_id', '')}/adimages"
120
+ image_params = {
121
+ "hashes": [image_hash]
122
+ }
123
+ image_data = await make_api_request(image_endpoint, access_token, image_params)
124
+ if "data" in image_data and len(image_data["data"]) > 0:
125
+ creative_data["full_image_url"] = image_data["data"][0].get("url")
126
+
127
+ # 2. For creatives with object_story_spec
128
+ if "object_story_spec" in creative_data:
129
+ spec = creative_data.get("object_story_spec", {})
130
+
131
+ # For link ads
132
+ if "link_data" in spec:
133
+ link_data = spec.get("link_data", {})
134
+ # If there's an explicit image_url, use it
135
+ if "image_url" in link_data:
136
+ creative_data["full_image_url"] = link_data.get("image_url")
137
+ # If there's an image_hash, try to get the full image
138
+ elif "image_hash" in link_data:
139
+ image_hash = link_data.get("image_hash")
140
+ account_id = ad_data.get('account_id', '')
141
+ if not account_id:
142
+ # Try to get account ID from ad ID
143
+ ad_details_endpoint = f"{ad_id}"
144
+ ad_details_params = {
145
+ "fields": "account_id"
146
+ }
147
+ ad_details = await make_api_request(ad_details_endpoint, access_token, ad_details_params)
148
+ account_id = ad_details.get('account_id', '')
149
+
150
+ if account_id:
151
+ image_endpoint = f"act_{account_id}/adimages"
152
+ image_params = {
153
+ "hashes": [image_hash]
154
+ }
155
+ image_data = await make_api_request(image_endpoint, access_token, image_params)
156
+ if "data" in image_data and len(image_data["data"]) > 0:
157
+ creative_data["full_image_url"] = image_data["data"][0].get("url")
158
+
159
+ # For photo ads
160
+ if "photo_data" in spec:
161
+ photo_data = spec.get("photo_data", {})
162
+ if "image_hash" in photo_data:
163
+ image_hash = photo_data.get("image_hash")
164
+ account_id = ad_data.get('account_id', '')
165
+ if not account_id:
166
+ # Try to get account ID from ad ID
167
+ ad_details_endpoint = f"{ad_id}"
168
+ ad_details_params = {
169
+ "fields": "account_id"
170
+ }
171
+ ad_details = await make_api_request(ad_details_endpoint, access_token, ad_details_params)
172
+ account_id = ad_details.get('account_id', '')
173
+
174
+ if account_id:
175
+ image_endpoint = f"act_{account_id}/adimages"
176
+ image_params = {
177
+ "hashes": [image_hash]
178
+ }
179
+ image_data = await make_api_request(image_endpoint, access_token, image_params)
180
+ if "data" in image_data and len(image_data["data"]) > 0:
181
+ creative_data["full_image_url"] = image_data["data"][0].get("url")
182
+
183
+ # 3. If there's an asset_feed_spec, try to get images from there
184
+ if "asset_feed_spec" in creative_data and "images" in creative_data["asset_feed_spec"]:
185
+ images = creative_data["asset_feed_spec"]["images"]
186
+ if images and len(images) > 0 and "hash" in images[0]:
187
+ image_hash = images[0]["hash"]
188
+ account_id = ad_data.get('account_id', '')
189
+ if not account_id:
190
+ # Try to get account ID
191
+ ad_details_endpoint = f"{ad_id}"
192
+ ad_details_params = {
193
+ "fields": "account_id"
194
+ }
195
+ ad_details = await make_api_request(ad_details_endpoint, access_token, ad_details_params)
196
+ account_id = ad_details.get('account_id', '')
197
+
198
+ if account_id:
199
+ image_endpoint = f"act_{account_id}/adimages"
200
+ image_params = {
201
+ "hashes": [image_hash]
202
+ }
203
+ image_data = await make_api_request(image_endpoint, access_token, image_params)
204
+ if "data" in image_data and len(image_data["data"]) > 0:
205
+ creative_data["full_image_url"] = image_data["data"][0].get("url")
206
+
207
+ # If we have a thumbnail_url but no full_image_url, let's attempt to convert the thumbnail URL to full size
208
+ if "thumbnail_url" in creative_data and "full_image_url" not in creative_data:
209
+ thumbnail_url = creative_data["thumbnail_url"]
210
+ # Try to convert the URL to get higher resolution by removing size parameters
211
+ if "p64x64" in thumbnail_url:
212
+ full_url = thumbnail_url.replace("p64x64", "p1080x1080")
213
+ creative_data["full_image_url"] = full_url
214
+ elif "dst-emg0" in thumbnail_url:
215
+ # Remove the dst-emg0 parameter that seems to reduce size
216
+ full_url = thumbnail_url.replace("dst-emg0_", "")
217
+ creative_data["full_image_url"] = full_url
218
+
219
+ # Fallback to using thumbnail or image_url if we still don't have a full image
220
+ if "full_image_url" not in creative_data:
221
+ if "thumbnail_url" in creative_data:
222
+ creative_data["full_image_url"] = creative_data["thumbnail_url"]
223
+ elif "image_url" in creative_data:
224
+ creative_data["full_image_url"] = creative_data["image_url"]
225
+
226
+ return json.dumps(creative_data, indent=2)
227
+
228
+
229
+ @meta_api_tool
230
+ async def get_ad_image(access_token: str = None, ad_id: str = None) -> Image:
231
+ """
232
+ Get, download, and visualize a Meta ad image in one step. Useful to see the image in the LLM.
233
+
234
+ Args:
235
+ access_token: Meta API access token (optional - will use cached token if not provided)
236
+ ad_id: Meta Ads ad ID
237
+
238
+ Returns:
239
+ The ad image ready for direct visual analysis
240
+ """
241
+ if not ad_id:
242
+ return "Error: No ad ID provided"
243
+
244
+ print(f"Attempting to get and analyze creative image for ad {ad_id}")
245
+
246
+ # First, get creative and account IDs
247
+ ad_endpoint = f"{ad_id}"
248
+ ad_params = {
249
+ "fields": "creative{id},account_id"
250
+ }
251
+
252
+ ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
253
+
254
+ if "error" in ad_data:
255
+ return f"Error: Could not get ad data - {json.dumps(ad_data)}"
256
+
257
+ # Extract account_id
258
+ account_id = ad_data.get("account_id", "")
259
+ if not account_id:
260
+ return "Error: No account ID found"
261
+
262
+ # Extract creative ID
263
+ if "creative" not in ad_data:
264
+ return "Error: No creative found for this ad"
265
+
266
+ creative_data = ad_data.get("creative", {})
267
+ creative_id = creative_data.get("id")
268
+ if not creative_id:
269
+ return "Error: No creative ID found"
270
+
271
+ # Get creative details to find image hash
272
+ creative_endpoint = f"{creative_id}"
273
+ creative_params = {
274
+ "fields": "id,name,image_hash,asset_feed_spec"
275
+ }
276
+
277
+ creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
278
+
279
+ # Identify image hashes to use from creative
280
+ image_hashes = []
281
+
282
+ # Check for direct image_hash on creative
283
+ if "image_hash" in creative_details:
284
+ image_hashes.append(creative_details["image_hash"])
285
+
286
+ # Check asset_feed_spec for image hashes - common in Advantage+ ads
287
+ if "asset_feed_spec" in creative_details and "images" in creative_details["asset_feed_spec"]:
288
+ for image in creative_details["asset_feed_spec"]["images"]:
289
+ if "hash" in image:
290
+ image_hashes.append(image["hash"])
291
+
292
+ if not image_hashes:
293
+ # If no hashes found, try to extract from the first creative we found in the API
294
+ # Get creative for ad to try to extract hash
295
+ creative_json = await get_ad_creatives(ad_id, "", access_token)
296
+ creative_data = json.loads(creative_json)
297
+
298
+ # Try to extract hash from asset_feed_spec
299
+ if "asset_feed_spec" in creative_data and "images" in creative_data["asset_feed_spec"]:
300
+ images = creative_data["asset_feed_spec"]["images"]
301
+ if images and len(images) > 0 and "hash" in images[0]:
302
+ image_hashes.append(images[0]["hash"])
303
+
304
+ if not image_hashes:
305
+ return "Error: No image hashes found in creative"
306
+
307
+ print(f"Found image hashes: {image_hashes}")
308
+
309
+ # Now fetch image data using adimages endpoint with specific format
310
+ image_endpoint = f"act_{account_id}/adimages"
311
+
312
+ # Format the hashes parameter exactly as in our successful curl test
313
+ hashes_str = f'["{image_hashes[0]}"]' # Format first hash only, as JSON string array
314
+
315
+ image_params = {
316
+ "fields": "hash,url,width,height,name,status",
317
+ "hashes": hashes_str
318
+ }
319
+
320
+ print(f"Requesting image data with params: {image_params}")
321
+ image_data = await make_api_request(image_endpoint, access_token, image_params)
322
+
323
+ if "error" in image_data:
324
+ return f"Error: Failed to get image data - {json.dumps(image_data)}"
325
+
326
+ if "data" not in image_data or not image_data["data"]:
327
+ return "Error: No image data returned from API"
328
+
329
+ # Get the first image URL
330
+ first_image = image_data["data"][0]
331
+ image_url = first_image.get("url")
332
+
333
+ if not image_url:
334
+ return "Error: No valid image URL found"
335
+
336
+ print(f"Downloading image from URL: {image_url}")
337
+
338
+ # Download the image
339
+ image_bytes = await download_image(image_url)
340
+
341
+ if not image_bytes:
342
+ return "Error: Failed to download image"
343
+
344
+ try:
345
+ # Convert bytes to PIL Image
346
+ img = PILImage.open(io.BytesIO(image_bytes))
347
+
348
+ # Convert to RGB if needed
349
+ if img.mode != "RGB":
350
+ img = img.convert("RGB")
351
+
352
+ # Create a byte stream of the image data
353
+ byte_arr = io.BytesIO()
354
+ img.save(byte_arr, format="JPEG")
355
+ img_bytes = byte_arr.getvalue()
356
+
357
+ # Return as an Image object that LLM can directly analyze
358
+ return Image(data=img_bytes, format="jpeg")
359
+
360
+ except Exception as e:
361
+ return f"Error processing image: {str(e)}"