#
tokens: 18384/50000 1/82 files (page 6/6)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 6/6FirstPrevNextLast