This is page 6 of 6. Use http://codebase.md/nictuku/meta-ads-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ └── workflows
│ ├── publish-mcp.yml
│ ├── publish.yml
│ └── test.yml
├── .gitignore
├── .python-version
├── .uv.toml
├── CUSTOM_META_APP.md
├── Dockerfile
├── examples
│ ├── example_http_client.py
│ └── README.md
├── future_improvements.md
├── images
│ └── meta-ads-example.png
├── LICENSE
├── LOCAL_INSTALLATION.md
├── meta_ads_auth.sh
├── meta_ads_mcp
│ ├── __init__.py
│ ├── __main__.py
│ └── core
│ ├── __init__.py
│ ├── accounts.py
│ ├── ads_library.py
│ ├── ads.py
│ ├── adsets.py
│ ├── api.py
│ ├── auth.py
│ ├── authentication.py
│ ├── budget_schedules.py
│ ├── callback_server.py
│ ├── campaigns.py
│ ├── duplication.py
│ ├── http_auth_integration.py
│ ├── insights.py
│ ├── openai_deep_research.py
│ ├── pipeboard_auth.py
│ ├── reports.py
│ ├── resources.py
│ ├── server.py
│ ├── targeting.py
│ └── utils.py
├── META_API_NOTES.md
├── poetry.lock
├── pyproject.toml
├── README.md
├── RELEASE.md
├── requirements.txt
├── server.json
├── setup.py
├── smithery.yaml
├── STREAMABLE_HTTP_SETUP.md
└── tests
├── __init__.py
├── conftest.py
├── e2e_account_info_search_issue.py
├── README_REGRESSION_TESTS.md
├── README.md
├── test_account_info_access_fix.py
├── test_account_search.py
├── test_budget_update_e2e.py
├── test_budget_update.py
├── test_create_ad_creative_simple.py
├── test_create_simple_creative_e2e.py
├── test_dsa_beneficiary.py
├── test_dsa_integration.py
├── test_duplication_regression.py
├── test_duplication.py
├── test_dynamic_creatives.py
├── test_estimate_audience_size_e2e.py
├── test_estimate_audience_size.py
├── test_get_account_pages.py
├── test_get_ad_creatives_fix.py
├── test_get_ad_image_quality_improvements.py
├── test_get_ad_image_regression.py
├── test_http_transport.py
├── test_insights_actions_and_values_e2e.py
├── test_insights_pagination.py
├── test_integration_openai_mcp.py
├── test_is_dynamic_creative_adset.py
├── test_mobile_app_adset_creation.py
├── test_mobile_app_adset_issue.py
├── test_openai_mcp_deep_research.py
├── test_openai.py
├── test_page_discovery_integration.py
├── test_page_discovery.py
├── test_targeting_search_e2e.py
├── test_targeting.py
├── test_update_ad_creative_id.py
└── test_upload_ad_image.py
```
# Files
--------------------------------------------------------------------------------
/meta_ads_mcp/core/ads.py:
--------------------------------------------------------------------------------
```python
1 | """Ad and Creative-related functionality for Meta Ads API."""
2 |
3 | import json
4 | from typing import Optional, Dict, Any, List
5 | import io
6 | from PIL import Image as PILImage
7 | from mcp.server.fastmcp import Image
8 | import os
9 | import time
10 |
11 | from .api import meta_api_tool, make_api_request
12 | from .accounts import get_ad_accounts
13 | from .utils import download_image, try_multiple_download_methods, ad_creative_images, extract_creative_image_urls
14 | from .server import mcp_server
15 |
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 |
21 | @mcp_server.tool()
22 | @meta_api_tool
23 | async def get_ads(account_id: str, access_token: Optional[str] = None, limit: int = 10,
24 | campaign_id: str = "", adset_id: str = "") -> str:
25 | """
26 | Get ads for a Meta Ads account with optional filtering.
27 |
28 | Args:
29 | account_id: Meta Ads account ID (format: act_XXXXXXXXX)
30 | access_token: Meta API access token (optional - will use cached token if not provided)
31 | limit: Maximum number of ads to return (default: 10)
32 | campaign_id: Optional campaign ID to filter by
33 | adset_id: Optional ad set ID to filter by
34 | """
35 | # Require explicit account_id
36 | if not account_id:
37 | return json.dumps({"error": "No account ID specified"}, indent=2)
38 |
39 | # Prioritize adset_id over campaign_id - use adset-specific endpoint
40 | if adset_id:
41 | endpoint = f"{adset_id}/ads"
42 | params = {
43 | "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
44 | "limit": limit
45 | }
46 | # Use campaign-specific endpoint if campaign_id is provided
47 | elif campaign_id:
48 | endpoint = f"{campaign_id}/ads"
49 | params = {
50 | "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
51 | "limit": limit
52 | }
53 | else:
54 | # Default to account-level endpoint if no specific filters
55 | endpoint = f"{account_id}/ads"
56 | params = {
57 | "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
58 | "limit": limit
59 | }
60 |
61 | data = await make_api_request(endpoint, access_token, params)
62 |
63 | return json.dumps(data, indent=2)
64 |
65 |
66 | @mcp_server.tool()
67 | @meta_api_tool
68 | async def get_ad_details(ad_id: str, access_token: Optional[str] = None) -> str:
69 | """
70 | Get detailed information about a specific ad.
71 |
72 | Args:
73 | ad_id: Meta Ads ad ID
74 | access_token: Meta API access token (optional - will use cached token if not provided)
75 | """
76 | if not ad_id:
77 | return json.dumps({"error": "No ad ID provided"}, indent=2)
78 |
79 | endpoint = f"{ad_id}"
80 | params = {
81 | "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs,preview_shareable_link"
82 | }
83 |
84 | data = await make_api_request(endpoint, access_token, params)
85 |
86 | return json.dumps(data, indent=2)
87 |
88 |
89 | @mcp_server.tool()
90 | @meta_api_tool
91 | async def create_ad(
92 | account_id: str,
93 | name: str,
94 | adset_id: str,
95 | creative_id: str,
96 | status: str = "PAUSED",
97 | bid_amount: Optional[int] = None,
98 | tracking_specs: Optional[List[Dict[str, Any]]] = None,
99 | access_token: Optional[str] = None
100 | ) -> str:
101 | """
102 | Create a new ad with an existing creative.
103 |
104 | Args:
105 | account_id: Meta Ads account ID (format: act_XXXXXXXXX)
106 | name: Ad name
107 | adset_id: Ad set ID where this ad will be placed
108 | creative_id: ID of an existing creative to use
109 | status: Initial ad status (default: PAUSED)
110 | bid_amount: Optional bid amount in account currency (in cents)
111 | tracking_specs: Optional tracking specifications (e.g., for pixel events).
112 | Example: [{"action.type":"offsite_conversion","fb_pixel":["YOUR_PIXEL_ID"]}]
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.
118 | """
119 | # Check required parameters
120 | if not account_id:
121 | return json.dumps({"error": "No account ID provided"}, indent=2)
122 |
123 | if not name:
124 | return json.dumps({"error": "No ad name provided"}, indent=2)
125 |
126 | if not adset_id:
127 | return json.dumps({"error": "No ad set ID provided"}, indent=2)
128 |
129 | if not creative_id:
130 | return json.dumps({"error": "No creative ID provided"}, indent=2)
131 |
132 | endpoint = f"{account_id}/ads"
133 |
134 | params = {
135 | "name": name,
136 | "adset_id": adset_id,
137 | "creative": {"creative_id": creative_id},
138 | "status": status
139 | }
140 |
141 | # Add bid amount if provided
142 | if bid_amount is not None:
143 | params["bid_amount"] = str(bid_amount)
144 |
145 | # Add tracking specs if provided
146 | if tracking_specs is not None:
147 | params["tracking_specs"] = json.dumps(tracking_specs) # Needs to be JSON encoded string
148 |
149 | try:
150 | data = await make_api_request(endpoint, access_token, params, method="POST")
151 | return json.dumps(data, indent=2)
152 | except Exception as e:
153 | error_msg = str(e)
154 | return json.dumps({
155 | "error": "Failed to create ad",
156 | "details": error_msg,
157 | "params_sent": params
158 | }, indent=2)
159 |
160 |
161 | @mcp_server.tool()
162 | @meta_api_tool
163 | async def get_ad_creatives(ad_id: str, access_token: Optional[str] = None) -> str:
164 | """
165 | Get creative details for a specific ad. Best if combined with get_ad_image to get the full image.
166 |
167 | Args:
168 | ad_id: Meta Ads ad ID
169 | access_token: Meta API access token (optional - will use cached token if not provided)
170 | """
171 | if not ad_id:
172 | return json.dumps({"error": "No ad ID provided"}, indent=2)
173 |
174 | endpoint = f"{ad_id}/adcreatives"
175 | params = {
176 | "fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,asset_feed_spec,image_urls_for_viewing"
177 | }
178 |
179 | data = await make_api_request(endpoint, access_token, params)
180 |
181 | # Add image URLs for direct viewing if available
182 | if 'data' in data:
183 | for creative in data['data']:
184 | creative['image_urls_for_viewing'] = extract_creative_image_urls(creative)
185 |
186 | return json.dumps(data, indent=2)
187 |
188 |
189 | @mcp_server.tool()
190 | @meta_api_tool
191 | async def get_ad_image(ad_id: str, access_token: Optional[str] = None) -> Image:
192 | """
193 | Get, download, and visualize a Meta ad image in one step. Useful to see the image in the LLM.
194 |
195 | Args:
196 | ad_id: Meta Ads ad ID
197 | access_token: Meta API access token (optional - will use cached token if not provided)
198 |
199 | Returns:
200 | The ad image ready for direct visual analysis
201 | """
202 | if not ad_id:
203 | return "Error: No ad ID provided"
204 |
205 | print(f"Attempting to get and analyze creative image for ad {ad_id}")
206 |
207 | # First, get creative and account IDs
208 | ad_endpoint = f"{ad_id}"
209 | ad_params = {
210 | "fields": "creative{id},account_id"
211 | }
212 |
213 | ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
214 |
215 | if "error" in ad_data:
216 | return f"Error: Could not get ad data - {json.dumps(ad_data)}"
217 |
218 | # Extract account_id
219 | account_id = ad_data.get("account_id", "")
220 | if not account_id:
221 | return "Error: No account ID found"
222 |
223 | # Extract creative ID
224 | if "creative" not in ad_data:
225 | return "Error: No creative found for this ad"
226 |
227 | creative_data = ad_data.get("creative", {})
228 | creative_id = creative_data.get("id")
229 | if not creative_id:
230 | return "Error: No creative ID found"
231 |
232 | # Get creative details to find image hash
233 | creative_endpoint = f"{creative_id}"
234 | creative_params = {
235 | "fields": "id,name,image_hash,asset_feed_spec"
236 | }
237 |
238 | creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
239 |
240 | # Identify image hashes to use from creative
241 | image_hashes = []
242 |
243 | # Check for direct image_hash on creative
244 | if "image_hash" in creative_details:
245 | image_hashes.append(creative_details["image_hash"])
246 |
247 | # Check asset_feed_spec for image hashes - common in Advantage+ ads
248 | if "asset_feed_spec" in creative_details and "images" in creative_details["asset_feed_spec"]:
249 | for image in creative_details["asset_feed_spec"]["images"]:
250 | if "hash" in image:
251 | image_hashes.append(image["hash"])
252 |
253 | if not image_hashes:
254 | # If no hashes found, try to extract from the first creative we found in the API
255 | # and also check for direct URLs as fallback
256 | creative_json = await get_ad_creatives(access_token=access_token, ad_id=ad_id)
257 | creative_data = json.loads(creative_json)
258 |
259 | # Try to extract hash from data array
260 | if "data" in creative_data and creative_data["data"]:
261 | for creative in creative_data["data"]:
262 | # Check object_story_spec for image hash
263 | if "object_story_spec" in creative and "link_data" in creative["object_story_spec"]:
264 | link_data = creative["object_story_spec"]["link_data"]
265 | if "image_hash" in link_data:
266 | image_hashes.append(link_data["image_hash"])
267 | # Check direct image_hash on creative
268 | elif "image_hash" in creative:
269 | image_hashes.append(creative["image_hash"])
270 | # Check asset_feed_spec for image hashes
271 | elif "asset_feed_spec" in creative and "images" in creative["asset_feed_spec"]:
272 | images = creative["asset_feed_spec"]["images"]
273 | if images and len(images) > 0 and "hash" in images[0]:
274 | image_hashes.append(images[0]["hash"])
275 |
276 | # If still no image hashes found, try direct URL fallback approach
277 | if not image_hashes:
278 | print("No image hashes found, trying direct URL fallback...")
279 |
280 | image_url = None
281 | if "data" in creative_data and creative_data["data"]:
282 | creative = creative_data["data"][0]
283 |
284 | # Prioritize higher quality image URLs in this order:
285 | # 1. image_urls_for_viewing (usually highest quality)
286 | # 2. image_url (direct field)
287 | # 3. object_story_spec.link_data.picture (usually full size)
288 | # 4. thumbnail_url (last resort - often profile thumbnail)
289 |
290 | if "image_urls_for_viewing" in creative and creative["image_urls_for_viewing"]:
291 | image_url = creative["image_urls_for_viewing"][0]
292 | print(f"Using image_urls_for_viewing: {image_url}")
293 | elif "image_url" in creative and creative["image_url"]:
294 | image_url = creative["image_url"]
295 | print(f"Using image_url: {image_url}")
296 | elif "object_story_spec" in creative and "link_data" in creative["object_story_spec"]:
297 | link_data = creative["object_story_spec"]["link_data"]
298 | if "picture" in link_data and link_data["picture"]:
299 | image_url = link_data["picture"]
300 | print(f"Using object_story_spec.link_data.picture: {image_url}")
301 | elif "thumbnail_url" in creative and creative["thumbnail_url"]:
302 | image_url = creative["thumbnail_url"]
303 | print(f"Using thumbnail_url (fallback): {image_url}")
304 |
305 | if not image_url:
306 | return "Error: No image URLs found in creative"
307 |
308 | # Download the image directly
309 | print(f"Downloading image from direct URL: {image_url}")
310 | image_bytes = await download_image(image_url)
311 |
312 | if not image_bytes:
313 | return "Error: Failed to download image from direct URL"
314 |
315 | try:
316 | # Convert bytes to PIL Image
317 | img = PILImage.open(io.BytesIO(image_bytes))
318 |
319 | # Convert to RGB if needed
320 | if img.mode != "RGB":
321 | img = img.convert("RGB")
322 |
323 | # Create a byte stream of the image data
324 | byte_arr = io.BytesIO()
325 | img.save(byte_arr, format="JPEG")
326 | img_bytes = byte_arr.getvalue()
327 |
328 | # Return as an Image object that LLM can directly analyze
329 | return Image(data=img_bytes, format="jpeg")
330 |
331 | except Exception as e:
332 | return f"Error processing image from direct URL: {str(e)}"
333 |
334 | print(f"Found image hashes: {image_hashes}")
335 |
336 | # Now fetch image data using adimages endpoint with specific format
337 | image_endpoint = f"act_{account_id}/adimages"
338 |
339 | # Format the hashes parameter exactly as in our successful curl test
340 | hashes_str = f'["{image_hashes[0]}"]' # Format first hash only, as JSON string array
341 |
342 | image_params = {
343 | "fields": "hash,url,width,height,name,status",
344 | "hashes": hashes_str
345 | }
346 |
347 | print(f"Requesting image data with params: {image_params}")
348 | image_data = await make_api_request(image_endpoint, access_token, image_params)
349 |
350 | if "error" in image_data:
351 | return f"Error: Failed to get image data - {json.dumps(image_data)}"
352 |
353 | if "data" not in image_data or not image_data["data"]:
354 | return "Error: No image data returned from API"
355 |
356 | # Get the first image URL
357 | first_image = image_data["data"][0]
358 | image_url = first_image.get("url")
359 |
360 | if not image_url:
361 | return "Error: No valid image URL found"
362 |
363 | print(f"Downloading image from URL: {image_url}")
364 |
365 | # Download the image
366 | image_bytes = await download_image(image_url)
367 |
368 | if not image_bytes:
369 | return "Error: Failed to download image"
370 |
371 | try:
372 | # Convert bytes to PIL Image
373 | img = PILImage.open(io.BytesIO(image_bytes))
374 |
375 | # Convert to RGB if needed
376 | if img.mode != "RGB":
377 | img = img.convert("RGB")
378 |
379 | # Create a byte stream of the image data
380 | byte_arr = io.BytesIO()
381 | img.save(byte_arr, format="JPEG")
382 | img_bytes = byte_arr.getvalue()
383 |
384 | # Return as an Image object that LLM can directly analyze
385 | return Image(data=img_bytes, format="jpeg")
386 |
387 | except Exception as e:
388 | return f"Error processing image: {str(e)}"
389 |
390 |
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.
397 |
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')
402 |
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'])
459 |
460 |
461 | if not image_hashes:
462 | return json.dumps({"error": "No image hashes found in creative or fallback"}, indent=2)
463 |
464 | print(f"Found image hashes: {image_hashes}")
465 |
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 | }
473 |
474 | print(f"Requesting image data with params: {image_params}")
475 | image_data = await make_api_request(image_endpoint, access_token, image_params)
476 |
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)
496 |
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)
506 |
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
513 |
514 | except Exception as e:
515 | return json.dumps({"error": f"Failed to save image: {str(e)}"}, indent=2)
516 |
517 |
518 | @mcp_server.tool()
519 | @meta_api_tool
520 | async def update_ad(
521 | ad_id: str,
522 | status: Optional[str] = None,
523 | bid_amount: Optional[int] = None,
524 | tracking_specs: Optional[List[Dict[str, Any]]] = None,
525 | creative_id: Optional[str] = None,
526 | access_token: Optional[str] = None
527 | ) -> str:
528 | """
529 | Update an ad with new settings.
530 |
531 | Args:
532 | ad_id: Meta Ads ad ID
533 | status: Update ad status (ACTIVE, PAUSED, etc.)
534 | bid_amount: Bid amount in account currency (in cents for USD)
535 | tracking_specs: Optional tracking specifications (e.g., for pixel events).
536 | creative_id: ID of the creative to associate with this ad (changes the ad's image/content)
537 | access_token: Meta API access token (optional - will use cached token if not provided)
538 | """
539 | if not ad_id:
540 | return json.dumps({"error": "Ad ID is required"}, indent=2)
541 |
542 | params = {}
543 | if status:
544 | params["status"] = status
545 | if bid_amount is not None:
546 | # Ensure bid_amount is sent as a string if it's not null
547 | params["bid_amount"] = str(bid_amount)
548 | if tracking_specs is not None: # Add tracking_specs to params if provided
549 | params["tracking_specs"] = json.dumps(tracking_specs) # Needs to be JSON encoded string
550 | if creative_id is not None:
551 | # Creative parameter needs to be a JSON object containing creative_id
552 | params["creative"] = json.dumps({"creative_id": creative_id})
553 |
554 | if not params:
555 | return json.dumps({"error": "No update parameters provided (status, bid_amount, tracking_specs, or creative_id)"}, indent=2)
556 |
557 | endpoint = f"{ad_id}"
558 | try:
559 | data = await make_api_request(endpoint, access_token, params, method='POST')
560 | return json.dumps(data, indent=2)
561 | except Exception as e:
562 | return json.dumps({"error": f"Failed to update ad: {str(e)}"}, indent=2)
563 |
564 |
565 | @mcp_server.tool()
566 | @meta_api_tool
567 | async def upload_ad_image(
568 | account_id: str,
569 | access_token: Optional[str] = None,
570 | file: Optional[str] = None,
571 | image_url: Optional[str] = None,
572 | name: Optional[str] = None
573 | ) -> str:
574 | """
575 | Upload an image to use in Meta Ads creatives.
576 |
577 | Args:
578 | account_id: Meta Ads account ID (format: act_XXXXXXXXX)
579 | access_token: Meta API access token (optional - will use cached token if not provided)
580 | file: Data URL or raw base64 string of the image (e.g., "data:image/png;base64,iVBORw0KG...")
581 | image_url: Direct URL to an image to fetch and upload
582 | name: Optional name for the image (default: filename)
583 |
584 | Returns:
585 | JSON response with image details including hash for creative creation
586 | """
587 | # Check required parameters
588 | if not account_id:
589 | return json.dumps({"error": "No account ID provided"}, indent=2)
590 |
591 | # Ensure we have image data
592 | if not file and not image_url:
593 | return json.dumps({"error": "Provide either 'file' (data URL or base64) or 'image_url'"}, indent=2)
594 |
595 | # Ensure account_id has the 'act_' prefix for API compatibility
596 | if not account_id.startswith("act_"):
597 | account_id = f"act_{account_id}"
598 |
599 | try:
600 | # Determine encoded_image (base64 string without data URL prefix) and a sensible name
601 | encoded_image: str = ""
602 | inferred_name: str = name or ""
603 |
604 | if file:
605 | # Support data URL (e.g., data:image/png;base64,...) and raw base64
606 | data_url_prefix = "data:"
607 | base64_marker = "base64,"
608 | if file.startswith(data_url_prefix) and base64_marker in file:
609 | header, base64_payload = file.split(base64_marker, 1)
610 | encoded_image = base64_payload.strip()
611 |
612 | # Infer file extension from MIME type if name not provided
613 | if not inferred_name:
614 | # Example header: data:image/png;...
615 | mime_type = header[len(data_url_prefix):].split(";")[0].strip()
616 | extension_map = {
617 | "image/png": ".png",
618 | "image/jpeg": ".jpg",
619 | "image/jpg": ".jpg",
620 | "image/webp": ".webp",
621 | "image/gif": ".gif",
622 | "image/bmp": ".bmp",
623 | "image/tiff": ".tiff",
624 | }
625 | ext = extension_map.get(mime_type, ".png")
626 | inferred_name = f"upload{ext}"
627 | else:
628 | # Assume it's already raw base64
629 | encoded_image = file.strip()
630 | if not inferred_name:
631 | inferred_name = "upload.png"
632 | else:
633 | # Download image from URL
634 | try:
635 | image_bytes = await try_multiple_download_methods(image_url)
636 | except Exception as download_error:
637 | return json.dumps({
638 | "error": "We couldn’t download the image from the link provided.",
639 | "reason": "The server returned an error while trying to fetch the image.",
640 | "image_url": image_url,
641 | "details": str(download_error),
642 | "suggestions": [
643 | "Make sure the link is publicly reachable (no login, VPN, or IP restrictions).",
644 | "If the image is hosted on a private app or server, move it to a public URL or a CDN and try again.",
645 | "Verify the URL is correct and serves the actual image file."
646 | ]
647 | }, indent=2)
648 |
649 | if not image_bytes:
650 | return json.dumps({
651 | "error": "We couldn’t access the image at the link you provided.",
652 | "reason": "The image link doesn’t appear to be publicly accessible or didn’t return any data.",
653 | "image_url": image_url,
654 | "suggestions": [
655 | "Double‑check that the link is public and does not require login, VPN, or IP allow‑listing.",
656 | "If the image is stored in a private app (for example, a self‑hosted gallery), upload it to a public URL or a CDN and try again.",
657 | "Confirm the URL is correct and points directly to an image file (e.g., .jpg, .png)."
658 | ]
659 | }, indent=2)
660 |
661 | import base64 # Local import
662 | encoded_image = base64.b64encode(image_bytes).decode("utf-8")
663 |
664 | # Infer name from URL if not provided
665 | if not inferred_name:
666 | try:
667 | path_no_query = image_url.split("?")[0]
668 | filename_from_url = os.path.basename(path_no_query)
669 | inferred_name = filename_from_url if filename_from_url else "upload.jpg"
670 | except Exception:
671 | inferred_name = "upload.jpg"
672 |
673 | # Final name resolution
674 | final_name = name or inferred_name or "upload.png"
675 |
676 | # Prepare the API endpoint for uploading images
677 | endpoint = f"{account_id}/adimages"
678 |
679 | # Prepare POST parameters expected by Meta API
680 | params = {
681 | "bytes": encoded_image,
682 | "name": final_name,
683 | }
684 |
685 | # Make API request to upload the image
686 | print(f"Uploading image to Facebook Ad Account {account_id}")
687 | data = await make_api_request(endpoint, access_token, params, method="POST")
688 |
689 | # Normalize/structure the response for callers (e.g., to easily grab image_hash)
690 | # Typical Graph API response shape:
691 | # { "images": { "<hash>": { "hash": "<hash>", "url": "...", "width": ..., "height": ..., "name": "...", "status": 1 } } }
692 | if isinstance(data, dict) and "images" in data and isinstance(data["images"], dict) and data["images"]:
693 | images_dict = data["images"]
694 | images_list = []
695 | for hash_key, info in images_dict.items():
696 | # Some responses may omit the nested hash, so ensure it's present
697 | normalized = {
698 | "hash": (info.get("hash") or hash_key),
699 | "url": info.get("url"),
700 | "width": info.get("width"),
701 | "height": info.get("height"),
702 | "name": info.get("name"),
703 | }
704 | # Drop null/None values
705 | normalized = {k: v for k, v in normalized.items() if v is not None}
706 | images_list.append(normalized)
707 |
708 | # Sort deterministically by hash
709 | images_list.sort(key=lambda i: i.get("hash", ""))
710 | primary_hash = images_list[0].get("hash") if images_list else None
711 |
712 | result = {
713 | "success": True,
714 | "account_id": account_id,
715 | "name": final_name,
716 | "image_hash": primary_hash,
717 | "images_count": len(images_list),
718 | "images": images_list
719 | }
720 | return json.dumps(result, indent=2)
721 |
722 | # If the API returned an error-like structure, surface it consistently
723 | if isinstance(data, dict) and "error" in data:
724 | return json.dumps({
725 | "error": "Failed to upload image",
726 | "details": data.get("error"),
727 | "account_id": account_id,
728 | "name": final_name
729 | }, indent=2)
730 |
731 | # Fallback: return a wrapped raw response to avoid breaking callers
732 | return json.dumps({
733 | "success": True,
734 | "account_id": account_id,
735 | "name": final_name,
736 | "raw_response": data
737 | }, indent=2)
738 |
739 | except Exception as e:
740 | return json.dumps({
741 | "error": "Failed to upload image",
742 | "details": str(e)
743 | }, indent=2)
744 |
745 |
746 | @mcp_server.tool()
747 | @meta_api_tool
748 | async def create_ad_creative(
749 | account_id: str,
750 | image_hash: str,
751 | access_token: Optional[str] = None,
752 | name: Optional[str] = None,
753 | page_id: Optional[str] = None,
754 | link_url: Optional[str] = None,
755 | message: Optional[str] = None,
756 | headline: Optional[str] = None,
757 | headlines: Optional[List[str]] = None,
758 | description: Optional[str] = None,
759 | descriptions: Optional[List[str]] = None,
760 | dynamic_creative_spec: Optional[Dict[str, Any]] = None,
761 | call_to_action_type: Optional[str] = None,
762 | instagram_actor_id: Optional[str] = None
763 | ) -> str:
764 | """
765 | Create a new ad creative using an uploaded image hash.
766 |
767 | Args:
768 | account_id: Meta Ads account ID (format: act_XXXXXXXXX)
769 | image_hash: Hash of the uploaded image
770 | access_token: Meta API access token (optional - will use cached token if not provided)
771 | name: Creative name
772 | page_id: Facebook Page ID to be used for the ad
773 | link_url: Destination URL for the ad
774 | message: Ad copy/text
775 | headline: Single headline for simple ads (cannot be used with headlines)
776 | headlines: List of headlines for dynamic creative testing (cannot be used with headline)
777 | description: Single description for simple ads (cannot be used with descriptions)
778 | descriptions: List of descriptions for dynamic creative testing (cannot be used with description)
779 | dynamic_creative_spec: Dynamic creative optimization settings
780 | call_to_action_type: Call to action button type (e.g., 'LEARN_MORE', 'SIGN_UP', 'SHOP_NOW')
781 | instagram_actor_id: Optional Instagram account ID for Instagram placements
782 |
783 | Returns:
784 | JSON response with created creative details
785 | """
786 | # Check required parameters
787 | if not account_id:
788 | return json.dumps({"error": "No account ID provided"}, indent=2)
789 |
790 | if not image_hash:
791 | return json.dumps({"error": "No image hash provided"}, indent=2)
792 |
793 | if not name:
794 | name = f"Creative {int(time.time())}"
795 |
796 | # Ensure account_id has the 'act_' prefix
797 | if not account_id.startswith("act_"):
798 | account_id = f"act_{account_id}"
799 |
800 | # Enhanced page discovery: If no page ID is provided, use robust discovery methods
801 | if not page_id:
802 | try:
803 | # Use the comprehensive page discovery logic from get_account_pages
804 | page_discovery_result = await _discover_pages_for_account(account_id, access_token)
805 |
806 | if page_discovery_result.get("success"):
807 | page_id = page_discovery_result["page_id"]
808 | page_name = page_discovery_result.get("page_name", "Unknown")
809 | print(f"Auto-discovered page ID: {page_id} ({page_name})")
810 | else:
811 | return json.dumps({
812 | "error": "No page ID provided and no suitable pages found for this account",
813 | "details": page_discovery_result.get("message", "Page discovery failed"),
814 | "suggestions": [
815 | "Use get_account_pages to see available pages",
816 | "Use search_pages_by_name to find specific pages",
817 | "Provide a page_id parameter manually"
818 | ]
819 | }, indent=2)
820 | except Exception as e:
821 | return json.dumps({
822 | "error": "Error during page discovery",
823 | "details": str(e),
824 | "suggestion": "Please provide a page_id parameter or use get_account_pages to find available pages"
825 | }, indent=2)
826 |
827 | # Validate headline/description parameters - cannot mix simple and complex
828 | if headline and headlines:
829 | return json.dumps({"error": "Cannot specify both 'headline' and 'headlines'. Use 'headline' for single headline or 'headlines' for multiple."}, indent=2)
830 |
831 | if description and descriptions:
832 | return json.dumps({"error": "Cannot specify both 'description' and 'descriptions'. Use 'description' for single description or 'descriptions' for multiple."}, indent=2)
833 |
834 | # Validate dynamic creative parameters (plural forms only)
835 | if headlines:
836 | if len(headlines) > 5:
837 | return json.dumps({"error": "Maximum 5 headlines allowed for dynamic creatives"}, indent=2)
838 | for i, h in enumerate(headlines):
839 | if len(h) > 40:
840 | return json.dumps({"error": f"Headline {i+1} exceeds 40 character limit"}, indent=2)
841 |
842 | if descriptions:
843 | if len(descriptions) > 5:
844 | return json.dumps({"error": "Maximum 5 descriptions allowed for dynamic creatives"}, indent=2)
845 | for i, d in enumerate(descriptions):
846 | if len(d) > 125:
847 | return json.dumps({"error": f"Description {i+1} exceeds 125 character limit"}, indent=2)
848 |
849 | # Prepare the creative data
850 | creative_data = {
851 | "name": name
852 | }
853 |
854 | # Choose between asset_feed_spec (dynamic creative) or object_story_spec (traditional)
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
858 | asset_feed_spec = {
859 | "ad_formats": ["SINGLE_IMAGE"],
860 | "images": [{"hash": image_hash}],
861 | "link_urls": [{"website_url": link_url if link_url else "https://facebook.com"}]
862 | }
863 |
864 | # Handle headlines
865 | if headlines:
866 | asset_feed_spec["headlines"] = [{"text": headline_text} for headline_text in headlines]
867 |
868 | # Handle descriptions
869 | if descriptions:
870 | asset_feed_spec["descriptions"] = [{"text": description_text} for description_text in descriptions]
871 |
872 | # Add message as primary_texts if provided
873 | if message:
874 | asset_feed_spec["primary_texts"] = [{"text": message}]
875 |
876 | # Add call_to_action_types if provided
877 | if call_to_action_type:
878 | asset_feed_spec["call_to_action_types"] = [call_to_action_type]
879 |
880 | creative_data["asset_feed_spec"] = asset_feed_spec
881 |
882 | # For dynamic creatives, we need a simplified object_story_spec
883 | creative_data["object_story_spec"] = {
884 | "page_id": page_id
885 | }
886 | else:
887 | # Use traditional object_story_spec with link_data for simple creatives
888 | creative_data["object_story_spec"] = {
889 | "page_id": page_id,
890 | "link_data": {
891 | "image_hash": image_hash,
892 | "link": link_url if link_url else "https://facebook.com"
893 | }
894 | }
895 |
896 | # Add optional parameters if provided
897 | if message:
898 | 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 | }
913 |
914 | # Add dynamic creative spec if provided
915 | if dynamic_creative_spec:
916 | creative_data["dynamic_creative_spec"] = dynamic_creative_spec
917 |
918 | if instagram_actor_id:
919 | creative_data["instagram_actor_id"] = instagram_actor_id
920 |
921 | # Prepare the API endpoint for creating a creative
922 | endpoint = f"{account_id}/adcreatives"
923 |
924 | try:
925 | # Make API request to create the creative
926 | data = await make_api_request(endpoint, access_token, creative_data, method="POST")
927 |
928 | # If successful, get more details about the created creative
929 | if "id" in data:
930 | creative_id = data["id"]
931 | creative_endpoint = f"{creative_id}"
932 | creative_params = {
933 | "fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,asset_feed_spec,url_tags,link_url"
934 | }
935 |
936 | creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
937 | return json.dumps({
938 | "success": True,
939 | "creative_id": creative_id,
940 | "details": creative_details
941 | }, indent=2)
942 |
943 | return json.dumps(data, indent=2)
944 |
945 | except Exception as e:
946 | return json.dumps({
947 | "error": "Failed to create ad creative",
948 | "details": str(e),
949 | "creative_data_sent": creative_data
950 | }, indent=2)
951 |
952 |
953 | @mcp_server.tool()
954 | @meta_api_tool
955 | async def update_ad_creative(
956 | creative_id: str,
957 | access_token: Optional[str] = None,
958 | name: Optional[str] = None,
959 | message: Optional[str] = None,
960 | headline: Optional[str] = None,
961 | headlines: Optional[List[str]] = None,
962 | description: Optional[str] = None,
963 | descriptions: Optional[List[str]] = None,
964 | dynamic_creative_spec: Optional[Dict[str, Any]] = None,
965 | call_to_action_type: Optional[str] = None
966 | ) -> str:
967 | """
968 | Update an existing ad creative with new content or settings.
969 |
970 | Args:
971 | creative_id: Meta Ads creative ID to update
972 | access_token: Meta API access token (optional - will use cached token if not provided)
973 | name: New creative name
974 | message: New ad copy/text
975 | headline: Single headline for simple ads (cannot be used with headlines)
976 | headlines: New list of headlines for dynamic creative testing (cannot be used with headline)
977 | description: Single description for simple ads (cannot be used with descriptions)
978 | descriptions: New list of descriptions for dynamic creative testing (cannot be used with description)
979 | dynamic_creative_spec: New dynamic creative optimization settings
980 | call_to_action_type: New call to action button type
981 |
982 | Returns:
983 | JSON response with updated creative details
984 | """
985 | # Check required parameters
986 | if not creative_id:
987 | return json.dumps({"error": "No creative ID provided"}, indent=2)
988 |
989 | # Validate headline/description parameters - cannot mix simple and complex
990 | if headline and headlines:
991 | return json.dumps({"error": "Cannot specify both 'headline' and 'headlines'. Use 'headline' for single headline or 'headlines' for multiple."}, indent=2)
992 |
993 | if description and descriptions:
994 | return json.dumps({"error": "Cannot specify both 'description' and 'descriptions'. Use 'description' for single description or 'descriptions' for multiple."}, indent=2)
995 |
996 | # Validate dynamic creative parameters (plural forms only)
997 | if headlines:
998 | if len(headlines) > 5:
999 | return json.dumps({"error": "Maximum 5 headlines allowed for dynamic creatives"}, indent=2)
1000 | for i, h in enumerate(headlines):
1001 | if len(h) > 40:
1002 | return json.dumps({"error": f"Headline {i+1} exceeds 40 character limit"}, indent=2)
1003 |
1004 | if descriptions:
1005 | if len(descriptions) > 5:
1006 | return json.dumps({"error": "Maximum 5 descriptions allowed for dynamic creatives"}, indent=2)
1007 | for i, d in enumerate(descriptions):
1008 | if len(d) > 125:
1009 | return json.dumps({"error": f"Description {i+1} exceeds 125 character limit"}, indent=2)
1010 |
1011 | # Prepare the update data
1012 | update_data = {}
1013 |
1014 | if name:
1015 | update_data["name"] = name
1016 |
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
1021 | asset_feed_spec = {}
1022 |
1023 | # Add required ad_formats field for dynamic creatives
1024 | asset_feed_spec["ad_formats"] = ["SINGLE_IMAGE"]
1025 |
1026 | # Handle headlines
1027 | if headlines:
1028 | asset_feed_spec["headlines"] = [{"text": headline_text} for headline_text in headlines]
1029 |
1030 | # Handle descriptions
1031 | if descriptions:
1032 | asset_feed_spec["descriptions"] = [{"text": description_text} for description_text in descriptions]
1033 |
1034 | # Add message as primary_texts if provided
1035 | if message:
1036 | asset_feed_spec["primary_texts"] = [{"text": message}]
1037 |
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 |
1042 | 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 | }
1064 |
1065 | # Add dynamic creative spec if provided
1066 | if dynamic_creative_spec:
1067 | update_data["dynamic_creative_spec"] = dynamic_creative_spec
1068 |
1069 | # Prepare the API endpoint for updating the creative
1070 | endpoint = f"{creative_id}"
1071 |
1072 | try:
1073 | # Make API request to update the creative
1074 | data = await make_api_request(endpoint, access_token, update_data, method="POST")
1075 |
1076 | # If successful, get more details about the updated creative
1077 | if "id" in data:
1078 | creative_endpoint = f"{creative_id}"
1079 | creative_params = {
1080 | "fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,url_tags,link_url,dynamic_creative_spec"
1081 | }
1082 |
1083 | creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
1084 | return json.dumps({
1085 | "success": True,
1086 | "creative_id": creative_id,
1087 | "details": creative_details
1088 | }, indent=2)
1089 |
1090 | return json.dumps(data, indent=2)
1091 |
1092 | except Exception as e:
1093 | return json.dumps({
1094 | "error": "Failed to update ad creative",
1095 | "details": str(e),
1096 | "update_data_sent": update_data
1097 | }, indent=2)
1098 |
1099 |
1100 | async def _discover_pages_for_account(account_id: str, access_token: str) -> dict:
1101 | """
1102 | Internal function to discover pages for an account using multiple approaches.
1103 | Returns the best available page ID for ad creation.
1104 | """
1105 | try:
1106 | # Approach 1: Extract page IDs from tracking_specs in ads (most reliable)
1107 | endpoint = f"{account_id}/ads"
1108 | params = {
1109 | "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
1110 | "limit": 100
1111 | }
1112 |
1113 | tracking_ads_data = await make_api_request(endpoint, access_token, params)
1114 |
1115 | tracking_page_ids = set()
1116 | if "data" in tracking_ads_data:
1117 | for ad in tracking_ads_data.get("data", []):
1118 | tracking_specs = ad.get("tracking_specs", [])
1119 | if isinstance(tracking_specs, list):
1120 | for spec in tracking_specs:
1121 | if isinstance(spec, dict) and "page" in spec:
1122 | page_list = spec["page"]
1123 | if isinstance(page_list, list):
1124 | for page_id in page_list:
1125 | if isinstance(page_id, (str, int)) and str(page_id).isdigit():
1126 | tracking_page_ids.add(str(page_id))
1127 |
1128 | if tracking_page_ids:
1129 | # Get details for the first page found
1130 | page_id = list(tracking_page_ids)[0]
1131 | page_endpoint = f"{page_id}"
1132 | page_params = {
1133 | "fields": "id,name,username,category,fan_count,link,verification_status,picture"
1134 | }
1135 |
1136 | page_data = await make_api_request(page_endpoint, access_token, page_params)
1137 | if "id" in page_data:
1138 | return {
1139 | "success": True,
1140 | "page_id": page_id,
1141 | "page_name": page_data.get("name", "Unknown"),
1142 | "source": "tracking_specs",
1143 | "note": "Page ID extracted from existing ads - most reliable for ad creation"
1144 | }
1145 |
1146 | # Approach 2: Try client_pages endpoint
1147 | endpoint = f"{account_id}/client_pages"
1148 | params = {
1149 | "fields": "id,name,username,category,fan_count,link,verification_status,picture"
1150 | }
1151 |
1152 | client_pages_data = await make_api_request(endpoint, access_token, params)
1153 |
1154 | if "data" in client_pages_data and client_pages_data["data"]:
1155 | page = client_pages_data["data"][0]
1156 | return {
1157 | "success": True,
1158 | "page_id": page["id"],
1159 | "page_name": page.get("name", "Unknown"),
1160 | "source": "client_pages"
1161 | }
1162 |
1163 | # Approach 3: Try assigned_pages endpoint
1164 | pages_endpoint = f"{account_id}/assigned_pages"
1165 | pages_params = {
1166 | "fields": "id,name",
1167 | "limit": 1
1168 | }
1169 |
1170 | pages_data = await make_api_request(pages_endpoint, access_token, pages_params)
1171 |
1172 | if "data" in pages_data and pages_data["data"]:
1173 | page = pages_data["data"][0]
1174 | return {
1175 | "success": True,
1176 | "page_id": page["id"],
1177 | "page_name": page.get("name", "Unknown"),
1178 | "source": "assigned_pages"
1179 | }
1180 |
1181 | # If all approaches failed
1182 | return {
1183 | "success": False,
1184 | "message": "No suitable pages found for this account",
1185 | "note": "Try using get_account_pages to see all available pages or provide page_id manually"
1186 | }
1187 |
1188 | except Exception as e:
1189 | return {
1190 | "success": False,
1191 | "message": f"Error during page discovery: {str(e)}"
1192 | }
1193 |
1194 |
1195 | async def _search_pages_by_name_core(access_token: str, account_id: str, search_term: str = None) -> str:
1196 | """
1197 | Core logic for searching pages by name.
1198 |
1199 | Args:
1200 | access_token: Meta API access token
1201 | account_id: Meta Ads account ID (format: act_XXXXXXXXX)
1202 | search_term: Search term to find pages by name (optional - returns all pages if not provided)
1203 |
1204 | Returns:
1205 | JSON string with search results
1206 | """
1207 | # Ensure account_id has the 'act_' prefix
1208 | if not account_id.startswith("act_"):
1209 | account_id = f"act_{account_id}"
1210 |
1211 | try:
1212 | # Use the internal discovery function directly
1213 | page_discovery_result = await _discover_pages_for_account(account_id, access_token)
1214 |
1215 | if not page_discovery_result.get("success"):
1216 | return json.dumps({
1217 | "data": [],
1218 | "message": "No pages found for this account",
1219 | "details": page_discovery_result.get("message", "Page discovery failed")
1220 | }, indent=2)
1221 |
1222 | # Create a single page result
1223 | page_data = {
1224 | "id": page_discovery_result["page_id"],
1225 | "name": page_discovery_result.get("page_name", "Unknown"),
1226 | "source": page_discovery_result.get("source", "unknown")
1227 | }
1228 |
1229 | all_pages_data = {"data": [page_data]}
1230 |
1231 | # Filter pages by search term if provided
1232 | if search_term:
1233 | search_term_lower = search_term.lower()
1234 | filtered_pages = []
1235 |
1236 | for page in all_pages_data["data"]:
1237 | page_name = page.get("name", "").lower()
1238 | if search_term_lower in page_name:
1239 | filtered_pages.append(page)
1240 |
1241 | return json.dumps({
1242 | "data": filtered_pages,
1243 | "search_term": search_term,
1244 | "total_found": len(filtered_pages),
1245 | "total_available": len(all_pages_data["data"])
1246 | }, indent=2)
1247 | else:
1248 | # Return all pages if no search term provided
1249 | return json.dumps({
1250 | "data": all_pages_data["data"],
1251 | "total_available": len(all_pages_data["data"]),
1252 | "note": "Use search_term parameter to filter pages by name"
1253 | }, indent=2)
1254 |
1255 | except Exception as e:
1256 | return json.dumps({
1257 | "error": "Failed to search pages by name",
1258 | "details": str(e)
1259 | }, indent=2)
1260 |
1261 |
1262 | @mcp_server.tool()
1263 | @meta_api_tool
1264 | async def search_pages_by_name(account_id: str, access_token: Optional[str] = None, search_term: Optional[str] = None) -> str:
1265 | """
1266 | Search for pages by name within an account.
1267 |
1268 | Args:
1269 | account_id: Meta Ads account ID (format: act_XXXXXXXXX)
1270 | access_token: Meta API access token (optional - will use cached token if not provided)
1271 | search_term: Search term to find pages by name (optional - returns all pages if not provided)
1272 |
1273 | Returns:
1274 | JSON response with matching pages
1275 | """
1276 | # Check required parameters
1277 | if not account_id:
1278 | return json.dumps({"error": "No account ID provided"}, indent=2)
1279 |
1280 | # Call the core function
1281 | result = await _search_pages_by_name_core(access_token, account_id, search_term)
1282 | return result
1283 |
1284 |
1285 | @mcp_server.tool()
1286 | @meta_api_tool
1287 | async def get_account_pages(account_id: str, access_token: Optional[str] = None) -> str:
1288 | """
1289 | Get pages associated with a Meta Ads account.
1290 |
1291 | Args:
1292 | account_id: Meta Ads account ID (format: act_XXXXXXXXX)
1293 | access_token: Meta API access token (optional - will use cached token if not provided)
1294 |
1295 | Returns:
1296 | JSON response with pages associated with the account
1297 | """
1298 | # Check required parameters
1299 | if not account_id:
1300 | return json.dumps({"error": "No account ID provided"}, indent=2)
1301 |
1302 | # Handle special case for 'me'
1303 | if account_id == "me":
1304 | try:
1305 | endpoint = "me/accounts"
1306 | params = {
1307 | "fields": "id,name,username,category,fan_count,link,verification_status,picture"
1308 | }
1309 |
1310 | user_pages_data = await make_api_request(endpoint, access_token, params)
1311 | return json.dumps(user_pages_data, indent=2)
1312 | except Exception as e:
1313 | return json.dumps({
1314 | "error": "Failed to get user pages",
1315 | "details": str(e)
1316 | }, indent=2)
1317 |
1318 | # Ensure account_id has the 'act_' prefix for regular accounts
1319 | if not account_id.startswith("act_"):
1320 | account_id = f"act_{account_id}"
1321 |
1322 | try:
1323 | # Collect all page IDs from multiple approaches
1324 | all_page_ids = set()
1325 |
1326 | # Approach 1: Get user's personal pages (broad scope)
1327 | try:
1328 | endpoint = "me/accounts"
1329 | params = {
1330 | "fields": "id,name,username,category,fan_count,link,verification_status,picture"
1331 | }
1332 | user_pages_data = await make_api_request(endpoint, access_token, params)
1333 | if "data" in user_pages_data:
1334 | for page in user_pages_data["data"]:
1335 | if "id" in page:
1336 | all_page_ids.add(page["id"])
1337 | except Exception:
1338 | pass
1339 |
1340 | # Approach 2: Try business manager pages
1341 | try:
1342 | # Strip 'act_' prefix to get raw account ID for business endpoints
1343 | raw_account_id = account_id.replace("act_", "")
1344 | endpoint = f"{raw_account_id}/owned_pages"
1345 | params = {
1346 | "fields": "id,name,username,category,fan_count,link,verification_status,picture"
1347 | }
1348 | business_pages_data = await make_api_request(endpoint, access_token, params)
1349 | if "data" in business_pages_data:
1350 | for page in business_pages_data["data"]:
1351 | if "id" in page:
1352 | all_page_ids.add(page["id"])
1353 | except Exception:
1354 | pass
1355 |
1356 | # Approach 3: Try ad account client pages
1357 | try:
1358 | endpoint = f"{account_id}/client_pages"
1359 | params = {
1360 | "fields": "id,name,username,category,fan_count,link,verification_status,picture"
1361 | }
1362 | client_pages_data = await make_api_request(endpoint, access_token, params)
1363 | if "data" in client_pages_data:
1364 | for page in client_pages_data["data"]:
1365 | if "id" in page:
1366 | all_page_ids.add(page["id"])
1367 | except Exception:
1368 | pass
1369 |
1370 | # Approach 4: Extract page IDs from all ad creatives (broader creative search)
1371 | try:
1372 | endpoint = f"{account_id}/adcreatives"
1373 | params = {
1374 | "fields": "id,name,object_story_spec,link_url,call_to_action,image_hash",
1375 | "limit": 100
1376 | }
1377 | creatives_data = await make_api_request(endpoint, access_token, params)
1378 | if "data" in creatives_data:
1379 | for creative in creatives_data["data"]:
1380 | if "object_story_spec" in creative and "page_id" in creative["object_story_spec"]:
1381 | all_page_ids.add(creative["object_story_spec"]["page_id"])
1382 | except Exception:
1383 | pass
1384 |
1385 | # Approach 5: Get active ads and extract page IDs from creatives
1386 | try:
1387 | endpoint = f"{account_id}/ads"
1388 | params = {
1389 | "fields": "creative{object_story_spec{page_id},link_url,call_to_action}",
1390 | "limit": 100
1391 | }
1392 | ads_data = await make_api_request(endpoint, access_token, params)
1393 | if "data" in ads_data:
1394 | for ad in ads_data.get("data", []):
1395 | if "creative" in ad and "object_story_spec" in ad["creative"] and "page_id" in ad["creative"]["object_story_spec"]:
1396 | all_page_ids.add(ad["creative"]["object_story_spec"]["page_id"])
1397 | except Exception:
1398 | pass
1399 |
1400 | # Approach 6: Try promoted_objects endpoint
1401 | try:
1402 | endpoint = f"{account_id}/promoted_objects"
1403 | params = {
1404 | "fields": "page_id,object_store_url,product_set_id,application_id"
1405 | }
1406 | promoted_objects_data = await make_api_request(endpoint, access_token, params)
1407 | if "data" in promoted_objects_data:
1408 | for obj in promoted_objects_data["data"]:
1409 | if "page_id" in obj:
1410 | all_page_ids.add(obj["page_id"])
1411 | except Exception:
1412 | pass
1413 |
1414 | # Approach 7: Extract page IDs from tracking_specs in ads (most reliable)
1415 | try:
1416 | endpoint = f"{account_id}/ads"
1417 | params = {
1418 | "fields": "id,name,status,creative,tracking_specs",
1419 | "limit": 100
1420 | }
1421 | tracking_ads_data = await make_api_request(endpoint, access_token, params)
1422 | if "data" in tracking_ads_data:
1423 | for ad in tracking_ads_data.get("data", []):
1424 | tracking_specs = ad.get("tracking_specs", [])
1425 | if isinstance(tracking_specs, list):
1426 | for spec in tracking_specs:
1427 | if isinstance(spec, dict) and "page" in spec:
1428 | page_list = spec["page"]
1429 | if isinstance(page_list, list):
1430 | for page_id in page_list:
1431 | if isinstance(page_id, (str, int)) and str(page_id).isdigit():
1432 | all_page_ids.add(str(page_id))
1433 | except Exception:
1434 | pass
1435 |
1436 | # Approach 8: Try campaigns and extract page info
1437 | try:
1438 | endpoint = f"{account_id}/campaigns"
1439 | params = {
1440 | "fields": "id,name,promoted_object,objective",
1441 | "limit": 50
1442 | }
1443 | campaigns_data = await make_api_request(endpoint, access_token, params)
1444 | if "data" in campaigns_data:
1445 | for campaign in campaigns_data["data"]:
1446 | if "promoted_object" in campaign and "page_id" in campaign["promoted_object"]:
1447 | all_page_ids.add(campaign["promoted_object"]["page_id"])
1448 | except Exception:
1449 | pass
1450 |
1451 | # If we found any page IDs, get details for each
1452 | if all_page_ids:
1453 | page_details = {
1454 | "data": [],
1455 | "total_pages_found": len(all_page_ids)
1456 | }
1457 |
1458 | for page_id in all_page_ids:
1459 | try:
1460 | page_endpoint = f"{page_id}"
1461 | page_params = {
1462 | "fields": "id,name,username,category,fan_count,link,verification_status,picture"
1463 | }
1464 |
1465 | page_data = await make_api_request(page_endpoint, access_token, page_params)
1466 | if "id" in page_data:
1467 | page_details["data"].append(page_data)
1468 | else:
1469 | page_details["data"].append({
1470 | "id": page_id,
1471 | "error": "Page details not accessible"
1472 | })
1473 | except Exception as e:
1474 | page_details["data"].append({
1475 | "id": page_id,
1476 | "error": f"Failed to get page details: {str(e)}"
1477 | })
1478 |
1479 | if page_details["data"]:
1480 | return json.dumps(page_details, indent=2)
1481 |
1482 | # If all approaches failed, return empty data with a message
1483 | return json.dumps({
1484 | "data": [],
1485 | "message": "No pages found associated with this account",
1486 | "suggestion": "Create a Facebook page and connect it to this ad account, or ensure existing pages are properly connected through Business Manager"
1487 | }, indent=2)
1488 |
1489 | except Exception as e:
1490 | return json.dumps({
1491 | "error": "Failed to get account pages",
1492 | "details": str(e)
1493 | }, indent=2)
1494 |
1495 |
1496 |
1497 |
1498 |
1499 |
```