meta-ads-mcp-python 1.0.79__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2751 @@
1
+ """Ad and Creative-related functionality for Meta Ads API."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from typing import Any, Dict, List, Optional, Union
7
+ import io
8
+
9
+ from fastmcp.utilities.types import Image
10
+ from loguru import logger
11
+ from PIL import Image as PILImage
12
+
13
+ from .api import meta_api_tool, make_api_request, ensure_act_prefix
14
+ from .accounts import get_ad_accounts
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Placement asset customization helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+ # Maps our user-friendly placement group names to Meta API positions.
21
+ # customization_spec in Meta's API is the placement SELECTOR (WHERE),
22
+ # while image_label/video_label at the rule level is the asset REFERENCE (WHAT).
23
+ _PLACEMENT_GROUP_TO_POSITIONS: Dict[str, Dict[str, List[str]]] = {
24
+ "FEED": {
25
+ "publisher_platforms": ["facebook", "instagram"],
26
+ "facebook_positions": ["feed"],
27
+ "instagram_positions": ["stream", "profile_feed"],
28
+ },
29
+ "STORY": {
30
+ "publisher_platforms": ["facebook", "instagram"],
31
+ "facebook_positions": ["story"],
32
+ "instagram_positions": ["story"],
33
+ },
34
+ "MESSENGER": {
35
+ "publisher_platforms": ["messenger"],
36
+ },
37
+ "INSTREAM_VIDEO": {
38
+ "publisher_platforms": ["facebook"],
39
+ "facebook_positions": ["instream_video"],
40
+ },
41
+ "SEARCH": {
42
+ "publisher_platforms": ["facebook"],
43
+ "facebook_positions": ["search"],
44
+ },
45
+ "SHOP": {
46
+ "publisher_platforms": ["instagram"],
47
+ "instagram_positions": ["shop"],
48
+ },
49
+ "AUDIENCE_NETWORK": {
50
+ "publisher_platforms": ["audience_network"],
51
+ "audience_network_positions": ["classic", "instream_video"],
52
+ },
53
+ }
54
+
55
+
56
+ def _translate_asset_customization_rules(
57
+ rules: List[Dict[str, Any]],
58
+ images_array: List[Dict[str, Any]],
59
+ ) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
60
+ """
61
+ Translate user-friendly placement_groups format to Meta API format.
62
+
63
+ Our user-facing format:
64
+ [{"placement_groups": ["FEED"], "customization_spec": {"image_hashes": ["h1"]}},
65
+ {"placement_groups": ["STORY"], "customization_spec": {"image_hashes": ["h2"]}}]
66
+
67
+ Meta API format:
68
+ [{"customization_spec": {"publisher_platforms": [...], "facebook_positions": [...]},
69
+ "image_label": {"name": "PBOARD_IMG_0"}},
70
+ ...]
71
+ And images in asset_feed_spec.images get adlabels assigned.
72
+
73
+ Rules that do NOT contain placement_groups are passed through unchanged
74
+ (allows raw Meta API format to be used directly).
75
+ """
76
+ if not rules or not any("placement_groups" in r for r in rules):
77
+ return rules, images_array
78
+
79
+ # Build hash -> label mapping across all rules
80
+ hash_to_label: Dict[str, str] = {}
81
+ label_counter = 0
82
+
83
+ translated_rules = []
84
+ for rule in rules:
85
+ if "placement_groups" not in rule:
86
+ translated_rules.append(rule)
87
+ continue
88
+
89
+ placement_groups = rule.get("placement_groups", [])
90
+ cspec_input = rule.get("customization_spec", {})
91
+
92
+ # Build Meta-format customization_spec from placement_groups
93
+ publisher_platforms: set = set()
94
+ facebook_positions: set = set()
95
+ instagram_positions: set = set()
96
+ audience_network_positions: set = set()
97
+
98
+ for pg in placement_groups:
99
+ mapping = _PLACEMENT_GROUP_TO_POSITIONS.get(pg, {})
100
+ publisher_platforms.update(mapping.get("publisher_platforms", []))
101
+ facebook_positions.update(mapping.get("facebook_positions", []))
102
+ instagram_positions.update(mapping.get("instagram_positions", []))
103
+ audience_network_positions.update(mapping.get("audience_network_positions", []))
104
+
105
+ meta_cspec: Dict[str, Any] = {}
106
+ if publisher_platforms:
107
+ meta_cspec["publisher_platforms"] = sorted(publisher_platforms)
108
+ if facebook_positions:
109
+ meta_cspec["facebook_positions"] = sorted(facebook_positions)
110
+ if instagram_positions:
111
+ meta_cspec["instagram_positions"] = sorted(instagram_positions)
112
+ if audience_network_positions:
113
+ meta_cspec["audience_network_positions"] = sorted(audience_network_positions)
114
+
115
+ # Carry over text overrides (bodies, titles, etc.) into customization_spec
116
+ for text_field in ("bodies", "titles", "descriptions", "link_urls", "call_to_action_types"):
117
+ if text_field in cspec_input:
118
+ meta_cspec[text_field] = cspec_input[text_field]
119
+
120
+ translated_rule: Dict[str, Any] = {"customization_spec": meta_cspec}
121
+
122
+ # Assign label for image or video asset
123
+ img_hashes = cspec_input.get("image_hashes", [])
124
+ vid_ids = cspec_input.get("video_ids", [])
125
+ if img_hashes:
126
+ h = img_hashes[0]
127
+ if h not in hash_to_label:
128
+ hash_to_label[h] = f"PBOARD_IMG_{label_counter}"
129
+ label_counter += 1
130
+ translated_rule["image_label"] = {"name": hash_to_label[h]}
131
+ elif vid_ids:
132
+ v = vid_ids[0]
133
+ if v not in hash_to_label:
134
+ hash_to_label[v] = f"PBOARD_VID_{label_counter}"
135
+ label_counter += 1
136
+ translated_rule["video_label"] = {"name": hash_to_label[v]}
137
+
138
+ translated_rules.append(translated_rule)
139
+
140
+ # Add adlabels to images_array for referenced hashes
141
+ updated_images = []
142
+ for img in images_array:
143
+ img_hash = img.get("hash", "")
144
+ if img_hash in hash_to_label:
145
+ updated = dict(img)
146
+ updated["adlabels"] = [{"name": hash_to_label[img_hash]}]
147
+ updated_images.append(updated)
148
+ else:
149
+ updated_images.append(img)
150
+
151
+ return translated_rules, updated_images
152
+
153
+
154
+ # All writable creative_features_spec keys for Meta Ads API v24+.
155
+ # Mirrors ALL_ENHANCEMENT_KEYS in pipeboard.co/lib/meta-ads-enhancement-keys.ts.
156
+ # Setting each key to {"enroll_status": "OPT_OUT"} disables the enhancement.
157
+ # NOTE: The legacy "standard_enhancements" key is deprecated for POST operations
158
+ # (Meta error subcode 3858504) -- individual keys must be used instead.
159
+ _ALL_ENHANCEMENT_KEYS: tuple[str, ...] = (
160
+ "add_text_overlay",
161
+ "creative_stickers",
162
+ "description_automation",
163
+ "image_animation",
164
+ "image_background_gen",
165
+ "image_templates",
166
+ "image_touchups",
167
+ "image_uncrop",
168
+ "inline_comment",
169
+ "media_type_automation",
170
+ "music_generation",
171
+ "pac_relaxation",
172
+ "product_extensions",
173
+ "profile_card",
174
+ "reveal_details_over_time",
175
+ "show_destination_blurbs",
176
+ "show_summary",
177
+ "site_extensions",
178
+ "text_optimizations",
179
+ "text_translation",
180
+ "translate_voiceover",
181
+ "video_auto_crop",
182
+ "video_highlights",
183
+ )
184
+
185
+
186
+ def _translate_video_customization_rules_for_existing_post(
187
+ rules: List[Dict[str, Any]],
188
+ ) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
189
+ """
190
+ Translate placement_groups-format customization rules to Meta API format,
191
+ building a videos array for use alongside object_story_id.
192
+
193
+ Used when object_story_id is combined with asset_customization_rules
194
+ to override specific placements (e.g., a 9:16 video for Story/Reels
195
+ while the organic post shows in feed).
196
+
197
+ Our user-facing format:
198
+ [{"placement_groups": ["STORY"], "customization_spec": {"video_ids": ["vid123"]}}]
199
+
200
+ Meta API format in asset_feed_spec:
201
+ videos: [{"video_id": "vid123", "adlabels": [{"name": "PBOARD_VID_0"}]}]
202
+ asset_customization_rules: [
203
+ {"customization_spec": {"publisher_platforms": [...], "instagram_positions": ["story"], ...},
204
+ "video_label": {"name": "PBOARD_VID_0"}}
205
+ ]
206
+
207
+ Rules that do NOT contain placement_groups are passed through unchanged
208
+ (allows raw Meta API format to be used directly).
209
+
210
+ Returns:
211
+ (translated_rules, videos_array) where videos_array has adlabels assigned.
212
+ """
213
+ if not rules or not any("placement_groups" in r for r in rules):
214
+ # Pass through raw rules if already in Meta API format
215
+ return rules, []
216
+
217
+ vid_to_label: Dict[str, str] = {}
218
+ label_counter = 0
219
+ translated_rules = []
220
+
221
+ for rule in rules:
222
+ if "placement_groups" not in rule:
223
+ translated_rules.append(rule)
224
+ continue
225
+
226
+ placement_groups = rule.get("placement_groups", [])
227
+ cspec_input = rule.get("customization_spec", {})
228
+
229
+ # Build Meta-format customization_spec from placement_groups
230
+ publisher_platforms: set = set()
231
+ facebook_positions: set = set()
232
+ instagram_positions: set = set()
233
+ audience_network_positions: set = set()
234
+
235
+ for pg in placement_groups:
236
+ mapping = _PLACEMENT_GROUP_TO_POSITIONS.get(pg, {})
237
+ publisher_platforms.update(mapping.get("publisher_platforms", []))
238
+ facebook_positions.update(mapping.get("facebook_positions", []))
239
+ instagram_positions.update(mapping.get("instagram_positions", []))
240
+ audience_network_positions.update(mapping.get("audience_network_positions", []))
241
+
242
+ meta_cspec: Dict[str, Any] = {}
243
+ if publisher_platforms:
244
+ meta_cspec["publisher_platforms"] = sorted(publisher_platforms)
245
+ if facebook_positions:
246
+ meta_cspec["facebook_positions"] = sorted(facebook_positions)
247
+ if instagram_positions:
248
+ meta_cspec["instagram_positions"] = sorted(instagram_positions)
249
+ if audience_network_positions:
250
+ meta_cspec["audience_network_positions"] = sorted(audience_network_positions)
251
+
252
+ # Carry over text overrides into customization_spec
253
+ for text_field in ("bodies", "titles", "descriptions", "link_urls", "call_to_action_types"):
254
+ if text_field in cspec_input:
255
+ meta_cspec[text_field] = cspec_input[text_field]
256
+
257
+ translated_rule: Dict[str, Any] = {"customization_spec": meta_cspec}
258
+
259
+ # Assign label for video asset
260
+ vid_ids = cspec_input.get("video_ids", [])
261
+ if vid_ids:
262
+ v = vid_ids[0]
263
+ if v not in vid_to_label:
264
+ vid_to_label[v] = f"PBOARD_VID_{label_counter}"
265
+ label_counter += 1
266
+ translated_rule["video_label"] = {"name": vid_to_label[v]}
267
+
268
+ translated_rules.append(translated_rule)
269
+
270
+ # Build videos_array with adlabels
271
+ videos_array = [
272
+ {"video_id": vid_id, "adlabels": [{"name": label}]}
273
+ for vid_id, label in vid_to_label.items()
274
+ ]
275
+
276
+ return translated_rules, videos_array
277
+
278
+
279
+ from .utils import download_image, try_multiple_download_methods, ad_creative_images, extract_creative_image_urls
280
+ from .server import mcp_server
281
+
282
+
283
+ # Only register the save_ad_image_locally function if explicitly enabled via environment variable
284
+ ENABLE_SAVE_AD_IMAGE_LOCALLY = bool(os.environ.get("META_ADS_ENABLE_SAVE_AD_IMAGE_LOCALLY", ""))
285
+
286
+
287
+ @mcp_server.tool()
288
+ @meta_api_tool
289
+ async def get_ads(account_id: str, access_token: Optional[str] = None, limit: int = 10,
290
+ campaign_id: str = "", adset_id: str = "") -> str:
291
+ """
292
+ Get ads for a Meta Ads account with optional filtering.
293
+
294
+ Args:
295
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX)
296
+ access_token: Meta API access token (optional - will use cached token if not provided)
297
+ limit: Maximum number of ads to return (default: 10)
298
+ campaign_id: Optional campaign ID to filter by
299
+ adset_id: Optional ad set ID to filter by
300
+ """
301
+ # Require explicit account_id
302
+ if not account_id:
303
+ return json.dumps({"error": "No account ID specified"}, indent=2)
304
+
305
+ # Prioritize adset_id over campaign_id - use adset-specific endpoint
306
+ if adset_id:
307
+ endpoint = f"{adset_id}/ads"
308
+ params = {
309
+ "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
310
+ "limit": limit
311
+ }
312
+ # Use campaign-specific endpoint if campaign_id is provided
313
+ elif campaign_id:
314
+ endpoint = f"{campaign_id}/ads"
315
+ params = {
316
+ "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
317
+ "limit": limit
318
+ }
319
+ else:
320
+ # Default to account-level endpoint if no specific filters
321
+ endpoint = f"{account_id}/ads"
322
+ params = {
323
+ "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
324
+ "limit": limit
325
+ }
326
+
327
+ data = await make_api_request(endpoint, access_token, params)
328
+
329
+ return json.dumps(data, indent=2)
330
+
331
+
332
+ @mcp_server.tool()
333
+ @meta_api_tool
334
+ async def get_ad_details(ad_id: str, access_token: Optional[str] = None) -> str:
335
+ """
336
+ Get detailed information about a specific ad.
337
+
338
+ Args:
339
+ ad_id: Meta Ads ad ID
340
+ access_token: Meta API access token (optional - will use cached token if not provided)
341
+ """
342
+ if not ad_id:
343
+ return json.dumps({"error": "No ad ID provided"}, indent=2)
344
+
345
+ endpoint = f"{ad_id}"
346
+ params = {
347
+ "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs,preview_shareable_link"
348
+ }
349
+
350
+ data = await make_api_request(endpoint, access_token, params)
351
+
352
+ return json.dumps(data, indent=2)
353
+
354
+
355
+ @mcp_server.tool()
356
+ @meta_api_tool
357
+ async def get_creative_details(creative_id: str, access_token: Optional[str] = None) -> str:
358
+ """Get detailed information about a specific ad creative by its ID.
359
+
360
+ Args:
361
+ creative_id: Meta Ads creative ID (required)
362
+ access_token: Meta API access token (optional)
363
+ """
364
+ if not creative_id:
365
+ return json.dumps({"error": "No creative ID provided"}, indent=2)
366
+ endpoint = f"{creative_id}"
367
+ # Note: dynamic_creative_spec is only valid on dynamic creatives and causes
368
+ # "(#100) Tried accessing nonexisting field" on simple creatives in API v24.
369
+ # We fetch the safe fields first, then try dynamic_creative_spec separately.
370
+ params = {
371
+ "fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,object_type,body,title,effective_object_story_id,asset_feed_spec{images,videos,bodies,titles,descriptions,link_urls,ad_formats,call_to_action_types,optimization_type,asset_customization_rules},url_tags,link_url"
372
+ }
373
+ data = await make_api_request(endpoint, access_token, params)
374
+
375
+ # Try to fetch optional fields separately (may not exist on all creative types)
376
+ if isinstance(data, dict) and "id" in data:
377
+ for opt_field in ["dynamic_creative_spec", "degrees_of_freedom_spec", "product_set_id"]:
378
+ try:
379
+ opt_data = await make_api_request(
380
+ endpoint, access_token, {"fields": opt_field}
381
+ )
382
+ if isinstance(opt_data, dict) and opt_field in opt_data:
383
+ data[opt_field] = opt_data[opt_field]
384
+ except Exception:
385
+ pass # Field doesn't exist on this creative type
386
+
387
+ # Resolve product_set_id -> catalog info for DPA/catalog creatives
388
+ if "product_set_id" in data:
389
+ try:
390
+ catalog_data = await make_api_request(
391
+ data["product_set_id"], access_token,
392
+ {"fields": "product_catalog{id,name}"}
393
+ )
394
+ catalog = catalog_data.get("product_catalog", {})
395
+ if catalog.get("id"):
396
+ data["catalog_id"] = catalog["id"]
397
+ if catalog.get("name"):
398
+ data["catalog_name"] = catalog["name"]
399
+ except Exception:
400
+ pass # Non-critical
401
+
402
+ return json.dumps(data, indent=2)
403
+
404
+
405
+ @meta_api_tool
406
+ async def create_ad(
407
+ account_id: str,
408
+ name: str,
409
+ adset_id: str,
410
+ creative_id: str,
411
+ status: str = "PAUSED",
412
+ bid_amount: Optional[int] = None,
413
+ tracking_specs: Optional[List[Dict[str, Any]]] = None,
414
+ access_token: Optional[str] = None
415
+ ) -> str:
416
+ """
417
+ Create a new ad with an existing creative.
418
+
419
+ Args:
420
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX)
421
+ name: Ad name
422
+ adset_id: Ad set ID where this ad will be placed
423
+ creative_id: ID of an existing creative to use
424
+ status: Initial ad status (default: PAUSED)
425
+ bid_amount: Optional bid amount in account currency (in cents)
426
+ tracking_specs: Optional tracking specifications (e.g., for pixel events).
427
+ Example: [{"action.type":"offsite_conversion","fb_pixel":["YOUR_PIXEL_ID"]}]
428
+ access_token: Meta API access token (optional - will use cached token if not provided)
429
+
430
+ Note:
431
+ Dynamic Creative creatives require the parent ad set to have `is_dynamic_creative=true`.
432
+ Otherwise, ad creation will fail with error_subcode 1885998.
433
+ """
434
+ # Check required parameters
435
+ if not account_id:
436
+ return json.dumps({"error": "No account ID provided"}, indent=2)
437
+
438
+ if not name:
439
+ return json.dumps({"error": "No ad name provided"}, indent=2)
440
+
441
+ if not adset_id:
442
+ return json.dumps({"error": "No ad set ID provided"}, indent=2)
443
+
444
+ if not creative_id:
445
+ return json.dumps({"error": "No creative ID provided"}, indent=2)
446
+
447
+ endpoint = f"{account_id}/ads"
448
+
449
+ params = {
450
+ "name": name,
451
+ "adset_id": adset_id,
452
+ "creative": {"creative_id": creative_id},
453
+ "status": status
454
+ }
455
+
456
+ # Add bid amount if provided
457
+ if bid_amount is not None:
458
+ params["bid_amount"] = str(bid_amount)
459
+
460
+ # Add tracking specs if provided
461
+ if tracking_specs is not None:
462
+ params["tracking_specs"] = json.dumps(tracking_specs) # Needs to be JSON encoded string
463
+
464
+ try:
465
+ data = await make_api_request(endpoint, access_token, params, method="POST")
466
+ return json.dumps(data, indent=2)
467
+ except Exception as e:
468
+ error_msg = str(e)
469
+ return json.dumps({
470
+ "error": "Failed to create ad",
471
+ "details": error_msg,
472
+ "params_sent": params
473
+ }, indent=2)
474
+
475
+
476
+ @mcp_server.tool()
477
+ @meta_api_tool
478
+ async def get_ad_creatives(ad_id: str, access_token: Optional[str] = None) -> str:
479
+ """
480
+ Get creative details for a specific ad. Requires an ad_id (not account_id). Use get_ads first to find ad IDs.
481
+
482
+ Args:
483
+ ad_id: Meta Ads ad ID (required)
484
+ access_token: Meta API access token (optional - will use cached token if not provided)
485
+ """
486
+ if not ad_id:
487
+ return json.dumps({"error": "No ad ID provided"}, indent=2)
488
+
489
+ endpoint = f"{ad_id}/adcreatives"
490
+ params = {
491
+ "fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,object_type,body,title,effective_object_story_id,asset_feed_spec,url_tags,image_urls_for_viewing,product_set_id,degrees_of_freedom_spec"
492
+ }
493
+
494
+ data = await make_api_request(endpoint, access_token, params)
495
+
496
+ if 'data' in data:
497
+ # Resolve asset_feed_spec image hashes to URLs
498
+ image_hashes = set()
499
+ for creative in data['data']:
500
+ if 'asset_feed_spec' in creative and 'images' in creative['asset_feed_spec']:
501
+ for image in creative['asset_feed_spec']['images']:
502
+ if 'hash' in image and 'url' not in image:
503
+ image_hashes.add(image['hash'])
504
+
505
+ if image_hashes:
506
+ # Get account_id from the ad to look up image URLs
507
+ ad_data = await make_api_request(ad_id, access_token, {"fields": "account_id"})
508
+ account_id = ad_data.get("account_id")
509
+ if account_id:
510
+ hashes_str = json.dumps(list(image_hashes))
511
+ image_data = await make_api_request(
512
+ f"act_{account_id}/adimages",
513
+ access_token,
514
+ {"fields": "hash,url,width,height", "hashes": hashes_str},
515
+ )
516
+ hash_to_url = {}
517
+ if 'data' in image_data:
518
+ for img in image_data['data']:
519
+ if 'hash' in img and 'url' in img:
520
+ hash_to_url[img['hash']] = img['url']
521
+
522
+ if hash_to_url:
523
+ for creative in data['data']:
524
+ if 'asset_feed_spec' in creative and 'images' in creative['asset_feed_spec']:
525
+ for image in creative['asset_feed_spec']['images']:
526
+ if 'hash' in image and image['hash'] in hash_to_url:
527
+ image['url'] = hash_to_url[image['hash']]
528
+
529
+ # Add image URLs for direct viewing if available
530
+ for creative in data['data']:
531
+ creative['image_urls_for_viewing'] = extract_creative_image_urls(creative)
532
+
533
+ # Resolve product_set_id -> catalog info for DPA/catalog creatives
534
+ for creative in data['data']:
535
+ ps_id = creative.get('product_set_id')
536
+ if ps_id:
537
+ try:
538
+ catalog_data = await make_api_request(
539
+ ps_id, access_token,
540
+ {"fields": "product_catalog{id,name}"}
541
+ )
542
+ catalog = catalog_data.get("product_catalog", {})
543
+ if catalog.get("id"):
544
+ creative["catalog_id"] = catalog["id"]
545
+ if catalog.get("name"):
546
+ creative["catalog_name"] = catalog["name"]
547
+ except Exception:
548
+ pass # Non-critical
549
+
550
+ return json.dumps(data, indent=2)
551
+
552
+
553
+ @mcp_server.tool()
554
+ @meta_api_tool
555
+ async def get_ad_image(ad_id: str, access_token: Optional[str] = None) -> Image:
556
+ """
557
+ Get, download, and visualize a Meta ad image in one step. Useful to see the image in the LLM.
558
+
559
+ Args:
560
+ ad_id: Meta Ads ad ID
561
+ access_token: Meta API access token (optional - will use cached token if not provided)
562
+
563
+ Returns:
564
+ The ad image ready for direct visual analysis
565
+ """
566
+ if not ad_id:
567
+ return "Error: No ad ID provided"
568
+
569
+ print(f"Attempting to get and analyze creative image for ad {ad_id}")
570
+
571
+ # First, get creative and account IDs
572
+ ad_endpoint = f"{ad_id}"
573
+ ad_params = {
574
+ "fields": "creative{id},account_id"
575
+ }
576
+
577
+ ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
578
+
579
+ if "error" in ad_data:
580
+ return f"Error: Could not get ad data - {json.dumps(ad_data)}"
581
+
582
+ # Extract account_id
583
+ account_id = ad_data.get("account_id", "")
584
+ if not account_id:
585
+ return "Error: No account ID found"
586
+
587
+ # Extract creative ID
588
+ if "creative" not in ad_data:
589
+ return "Error: No creative found for this ad"
590
+
591
+ creative_data = ad_data.get("creative", {})
592
+ creative_id = creative_data.get("id")
593
+ if not creative_id:
594
+ return "Error: No creative ID found"
595
+
596
+ # Get creative details to find image hash
597
+ creative_endpoint = f"{creative_id}"
598
+ creative_params = {
599
+ "fields": "id,name,image_hash,asset_feed_spec"
600
+ }
601
+
602
+ creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
603
+
604
+ # Identify image hashes to use from creative
605
+ image_hashes = []
606
+
607
+ # Check for direct image_hash on creative
608
+ if "image_hash" in creative_details:
609
+ image_hashes.append(creative_details["image_hash"])
610
+
611
+ # Check asset_feed_spec for image hashes - common in Advantage+ ads
612
+ if "asset_feed_spec" in creative_details and "images" in creative_details["asset_feed_spec"]:
613
+ for image in creative_details["asset_feed_spec"]["images"]:
614
+ if "hash" in image:
615
+ image_hashes.append(image["hash"])
616
+
617
+ if not image_hashes:
618
+ # If no hashes found, try to extract from the first creative we found in the API
619
+ # and also check for direct URLs as fallback
620
+ creative_json = await get_ad_creatives(access_token=access_token, ad_id=ad_id)
621
+ creative_data = json.loads(creative_json)
622
+
623
+ # Try to extract hash from data array
624
+ if "data" in creative_data and creative_data["data"]:
625
+ for creative in creative_data["data"]:
626
+ # Check object_story_spec for image hash
627
+ if "object_story_spec" in creative and "link_data" in creative["object_story_spec"]:
628
+ link_data = creative["object_story_spec"]["link_data"]
629
+ if "image_hash" in link_data:
630
+ image_hashes.append(link_data["image_hash"])
631
+ # Check direct image_hash on creative
632
+ elif "image_hash" in creative:
633
+ image_hashes.append(creative["image_hash"])
634
+ # Check asset_feed_spec for image hashes
635
+ elif "asset_feed_spec" in creative and "images" in creative["asset_feed_spec"]:
636
+ images = creative["asset_feed_spec"]["images"]
637
+ if images and len(images) > 0 and "hash" in images[0]:
638
+ image_hashes.append(images[0]["hash"])
639
+
640
+ # If still no image hashes found, try direct URL fallback approach
641
+ if not image_hashes:
642
+ print("No image hashes found, trying direct URL fallback...")
643
+
644
+ image_url = None
645
+ if "data" in creative_data and creative_data["data"]:
646
+ creative = creative_data["data"][0]
647
+
648
+ # Prioritize higher quality image URLs in this order:
649
+ # 1. image_urls_for_viewing (usually highest quality)
650
+ # 2. image_url (direct field)
651
+ # 3. object_story_spec.link_data.picture (usually full size)
652
+ # 4. thumbnail_url (last resort - often profile thumbnail)
653
+
654
+ if "image_urls_for_viewing" in creative and creative["image_urls_for_viewing"]:
655
+ image_url = creative["image_urls_for_viewing"][0]
656
+ print(f"Using image_urls_for_viewing: {image_url}")
657
+ elif "image_url" in creative and creative["image_url"]:
658
+ image_url = creative["image_url"]
659
+ print(f"Using image_url: {image_url}")
660
+ elif "object_story_spec" in creative and "link_data" in creative["object_story_spec"]:
661
+ link_data = creative["object_story_spec"]["link_data"]
662
+ if "picture" in link_data and link_data["picture"]:
663
+ image_url = link_data["picture"]
664
+ print(f"Using object_story_spec.link_data.picture: {image_url}")
665
+ elif "thumbnail_url" in creative and creative["thumbnail_url"]:
666
+ image_url = creative["thumbnail_url"]
667
+ print(f"Using thumbnail_url (fallback): {image_url}")
668
+
669
+ if not image_url:
670
+ return "Error: No image URLs found in creative"
671
+
672
+ # Download the image directly
673
+ print(f"Downloading image from direct URL: {image_url}")
674
+ image_bytes = await download_image(image_url)
675
+
676
+ if not image_bytes:
677
+ return "Error: Failed to download image from direct URL"
678
+
679
+ try:
680
+ # Convert bytes to PIL Image
681
+ img = PILImage.open(io.BytesIO(image_bytes))
682
+
683
+ # Convert to RGB if needed
684
+ if img.mode != "RGB":
685
+ img = img.convert("RGB")
686
+
687
+ # Create a byte stream of the image data
688
+ byte_arr = io.BytesIO()
689
+ img.save(byte_arr, format="JPEG")
690
+ img_bytes = byte_arr.getvalue()
691
+
692
+ # Return as an Image object that LLM can directly analyze
693
+ return Image(data=img_bytes, format="jpeg")
694
+
695
+ except Exception as e:
696
+ return f"Error processing image from direct URL: {str(e)}"
697
+
698
+ print(f"Found image hashes: {image_hashes}")
699
+
700
+ # Now fetch image data using adimages endpoint with specific format
701
+ image_endpoint = f"act_{account_id}/adimages"
702
+
703
+ # Format the hashes parameter exactly as in our successful curl test
704
+ hashes_str = f'["{image_hashes[0]}"]' # Format first hash only, as JSON string array
705
+
706
+ image_params = {
707
+ "fields": "hash,url,width,height,name,status",
708
+ "hashes": hashes_str
709
+ }
710
+
711
+ print(f"Requesting image data with params: {image_params}")
712
+ image_data = await make_api_request(image_endpoint, access_token, image_params)
713
+
714
+ if "error" in image_data:
715
+ return f"Error: Failed to get image data - {json.dumps(image_data)}"
716
+
717
+ if "data" not in image_data or not image_data["data"]:
718
+ return "Error: No image data returned from API"
719
+
720
+ # Get the first image URL
721
+ first_image = image_data["data"][0]
722
+ image_url = first_image.get("url")
723
+
724
+ if not image_url:
725
+ return "Error: No valid image URL found"
726
+
727
+ print(f"Downloading image from URL: {image_url}")
728
+
729
+ # Download the image
730
+ image_bytes = await download_image(image_url)
731
+
732
+ if not image_bytes:
733
+ return "Error: Failed to download image"
734
+
735
+ try:
736
+ # Convert bytes to PIL Image
737
+ img = PILImage.open(io.BytesIO(image_bytes))
738
+
739
+ # Convert to RGB if needed
740
+ if img.mode != "RGB":
741
+ img = img.convert("RGB")
742
+
743
+ # Create a byte stream of the image data
744
+ byte_arr = io.BytesIO()
745
+ img.save(byte_arr, format="JPEG")
746
+ img_bytes = byte_arr.getvalue()
747
+
748
+ # Return as an Image object that LLM can directly analyze
749
+ return Image(data=img_bytes, format="jpeg")
750
+
751
+ except Exception as e:
752
+ return f"Error processing image: {str(e)}"
753
+
754
+
755
+ @mcp_server.tool()
756
+ @meta_api_tool
757
+ async def get_ad_video(ad_id: str = "", video_id: str = "", account_id: str = "", access_token: Optional[str] = None) -> str:
758
+ """
759
+ Get video details and source URL for a Meta ad video creative. Returns the video source URL
760
+ (direct download link), thumbnail URL, and metadata (title, description, duration).
761
+
762
+ Provide either ad_id (to auto-extract the video from the ad creative) or video_id directly.
763
+ Providing account_id is strongly recommended -- it enables the advideos edge which works
764
+ with Business Manager tokens (avoids error 100/33 and error #10 on account-uploaded videos).
765
+
766
+ Args:
767
+ ad_id: Meta Ads ad ID (will extract video_id from the ad creative)
768
+ video_id: Meta video ID (use this if you already have it from get_ad_creatives)
769
+ account_id: Ad account ID (e.g. "act_123" or "123"). Enables advideos edge lookup.
770
+ access_token: Meta API access token (optional - will use cached token if not provided)
771
+ """
772
+ if not ad_id and not video_id:
773
+ return json.dumps({"error": "Provide either ad_id or video_id"}, indent=2)
774
+
775
+ # If only ad_id provided, extract video_id from the creative
776
+ if not video_id:
777
+ creative_json = await get_ad_creatives(access_token=access_token, ad_id=ad_id)
778
+ creative_data = json.loads(creative_json)
779
+
780
+ if "error" in creative_data:
781
+ return json.dumps({"error": f"Could not get creatives for ad {ad_id}", "details": creative_data}, indent=2)
782
+
783
+ # Extract video_id from creative data
784
+ if "data" in creative_data and creative_data["data"]:
785
+ creative = creative_data["data"][0]
786
+
787
+ # Check object_story_spec.video_data.video_id
788
+ oss = creative.get("object_story_spec", {})
789
+ if "video_data" in oss:
790
+ video_id = str(oss["video_data"].get("video_id", ""))
791
+
792
+ # Check asset_feed_spec.videos
793
+ if not video_id:
794
+ afs = creative.get("asset_feed_spec", {})
795
+ videos = afs.get("videos", [])
796
+ if videos:
797
+ video_id = str(videos[0].get("video_id", ""))
798
+
799
+ if not video_id:
800
+ return json.dumps({
801
+ "error": "No video found in this ad creative",
802
+ "hint": "This ad may be an image ad. Use get_ad_image instead."
803
+ }, indent=2)
804
+
805
+ video_fields = "source,title,description,length,picture,thumbnails,created_time"
806
+
807
+ # Strategy 1: Try fetching via the ad account's advideos edge.
808
+ # Direct GET /{video_id} fails for BM-shared tokens (error 100/33) and
809
+ # page-owned videos (error #10). The ad account edge works for any video
810
+ # that belongs to the account's video library.
811
+ # Normalize: strip act_ prefix if present (we add it back below)
812
+ if account_id and account_id.startswith("act_"):
813
+ account_id = account_id[4:]
814
+
815
+ if not account_id and ad_id:
816
+ ad_data = await make_api_request(ad_id, access_token, {"fields": "account_id"})
817
+ account_id = ad_data.get("account_id", "")
818
+
819
+ video_data = None
820
+ if account_id:
821
+ advideos_data = await make_api_request(
822
+ f"act_{account_id}/advideos",
823
+ access_token,
824
+ {
825
+ "fields": video_fields,
826
+ "filtering": json.dumps([{"field": "id", "operator": "IN", "value": [video_id]}]),
827
+ },
828
+ )
829
+ if "data" in advideos_data and advideos_data["data"]:
830
+ video_data = advideos_data["data"][0]
831
+ logger.debug(f"Video {video_id} resolved via ad account advideos edge")
832
+
833
+ # Strategy 2: Fall back to direct video node access.
834
+ if not video_data:
835
+ video_data = await make_api_request(
836
+ video_id,
837
+ access_token,
838
+ {"fields": video_fields}
839
+ )
840
+
841
+ if "error" in video_data:
842
+ return json.dumps({"error": f"Could not get video {video_id}", "details": video_data}, indent=2)
843
+
844
+ result = {
845
+ "video_id": video_id,
846
+ "source_url": video_data.get("source"),
847
+ "thumbnail_url": video_data.get("picture"),
848
+ "title": video_data.get("title"),
849
+ "description": video_data.get("description"),
850
+ "duration_seconds": video_data.get("length"),
851
+ "created_time": video_data.get("created_time"),
852
+ }
853
+
854
+ if ad_id:
855
+ result["ad_id"] = ad_id
856
+
857
+ if not result["source_url"]:
858
+ result["warning"] = "No source URL returned. The video may have been deleted or you may lack permissions."
859
+
860
+ return json.dumps(result, indent=2)
861
+
862
+
863
+ if ENABLE_SAVE_AD_IMAGE_LOCALLY:
864
+ @mcp_server.tool()
865
+ @meta_api_tool
866
+ async def save_ad_image_locally(ad_id: str, access_token: Optional[str] = None, output_dir: str = "ad_images") -> str:
867
+ """
868
+ Get, download, and save a Meta ad image locally, returning the file path.
869
+
870
+ Args:
871
+ ad_id: Meta Ads ad ID
872
+ access_token: Meta API access token (optional - will use cached token if not provided)
873
+ output_dir: Directory to save the image file (default: 'ad_images')
874
+
875
+ Returns:
876
+ The file path to the saved image, or an error message string.
877
+ """
878
+ if not ad_id:
879
+ return json.dumps({"error": "No ad ID provided"}, indent=2)
880
+
881
+ print(f"Attempting to get and save creative image for ad {ad_id}")
882
+
883
+ # First, get creative and account IDs
884
+ ad_endpoint = f"{ad_id}"
885
+ ad_params = {
886
+ "fields": "creative{id},account_id"
887
+ }
888
+
889
+ ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
890
+
891
+ if "error" in ad_data:
892
+ return json.dumps({"error": f"Could not get ad data - {json.dumps(ad_data)}"}, indent=2)
893
+
894
+ account_id = ad_data.get("account_id")
895
+ if not account_id:
896
+ return json.dumps({"error": "No account ID found for ad"}, indent=2)
897
+
898
+ if "creative" not in ad_data:
899
+ return json.dumps({"error": "No creative found for this ad"}, indent=2)
900
+
901
+ creative_data = ad_data.get("creative", {})
902
+ creative_id = creative_data.get("id")
903
+ if not creative_id:
904
+ return json.dumps({"error": "No creative ID found"}, indent=2)
905
+
906
+ # Get creative details to find image hash
907
+ creative_endpoint = f"{creative_id}"
908
+ creative_params = {
909
+ "fields": "id,name,image_hash,asset_feed_spec"
910
+ }
911
+ creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
912
+
913
+ image_hashes = []
914
+ if "image_hash" in creative_details:
915
+ image_hashes.append(creative_details["image_hash"])
916
+ if "asset_feed_spec" in creative_details and "images" in creative_details["asset_feed_spec"]:
917
+ for image in creative_details["asset_feed_spec"]["images"]:
918
+ if "hash" in image:
919
+ image_hashes.append(image["hash"])
920
+
921
+ if not image_hashes:
922
+ # Fallback attempt (as in get_ad_image)
923
+ creative_json = await get_ad_creatives(ad_id=ad_id, access_token=access_token) # Ensure ad_id is passed correctly
924
+ creative_data_list = json.loads(creative_json)
925
+ if 'data' in creative_data_list and creative_data_list['data']:
926
+ first_creative = creative_data_list['data'][0]
927
+ 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']:
928
+ image_hashes.append(first_creative['object_story_spec']['link_data']['image_hash'])
929
+ elif 'image_hash' in first_creative: # Check direct hash on creative data
930
+ image_hashes.append(first_creative['image_hash'])
931
+
932
+
933
+ if not image_hashes:
934
+ return json.dumps({"error": "No image hashes found in creative or fallback"}, indent=2)
935
+
936
+ print(f"Found image hashes: {image_hashes}")
937
+
938
+ # Fetch image data using the first hash
939
+ image_endpoint = f"act_{account_id}/adimages"
940
+ hashes_str = f'["{image_hashes[0]}"]'
941
+ image_params = {
942
+ "fields": "hash,url,width,height,name,status",
943
+ "hashes": hashes_str
944
+ }
945
+
946
+ print(f"Requesting image data with params: {image_params}")
947
+ image_data = await make_api_request(image_endpoint, access_token, image_params)
948
+
949
+ if "error" in image_data:
950
+ return json.dumps({"error": f"Failed to get image data - {json.dumps(image_data)}"}, indent=2)
951
+
952
+ if "data" not in image_data or not image_data["data"]:
953
+ return json.dumps({"error": "No image data returned from API"}, indent=2)
954
+
955
+ first_image = image_data["data"][0]
956
+ image_url = first_image.get("url")
957
+
958
+ if not image_url:
959
+ return json.dumps({"error": "No valid image URL found in API response"}, indent=2)
960
+
961
+ print(f"Downloading image from URL: {image_url}")
962
+
963
+ # Download and Save Image
964
+ image_bytes = await download_image(image_url)
965
+
966
+ if not image_bytes:
967
+ return json.dumps({"error": "Failed to download image"}, indent=2)
968
+
969
+ try:
970
+ # Ensure output directory exists
971
+ if not os.path.exists(output_dir):
972
+ os.makedirs(output_dir)
973
+
974
+ # Create a filename (e.g., using ad_id and image hash)
975
+ file_extension = ".jpg" # Default extension, could try to infer from headers later
976
+ filename = f"{ad_id}_{image_hashes[0]}{file_extension}"
977
+ filepath = os.path.join(output_dir, filename)
978
+
979
+ # Save the image bytes to the file
980
+ with open(filepath, "wb") as f:
981
+ f.write(image_bytes)
982
+
983
+ print(f"Image saved successfully to: {filepath}")
984
+ return json.dumps({"filepath": filepath}, indent=2) # Return JSON with filepath
985
+
986
+ except Exception as e:
987
+ return json.dumps({"error": f"Failed to save image: {str(e)}"}, indent=2)
988
+
989
+
990
+ @meta_api_tool
991
+ async def update_ad(
992
+ ad_id: str,
993
+ name: Optional[str] = None,
994
+ status: Optional[str] = None,
995
+ bid_amount: Optional[int] = None,
996
+ tracking_specs: Optional[List[Dict[str, Any]]] = None,
997
+ creative_id: Optional[Union[str, int]] = None,
998
+ access_token: Optional[str] = None
999
+ ) -> str:
1000
+ """
1001
+ Update an ad with new settings.
1002
+
1003
+ Args:
1004
+ ad_id: Meta Ads ad ID
1005
+ name: New ad name
1006
+ status: Update ad status (ACTIVE, PAUSED, etc.)
1007
+ bid_amount: Bid amount in account currency (in cents for USD)
1008
+ tracking_specs: Optional tracking specifications (e.g., for pixel events).
1009
+ creative_id: ID of the creative to associate with this ad (changes the ad's image/content)
1010
+ access_token: Meta API access token (optional - will use cached token if not provided)
1011
+ """
1012
+ if not ad_id:
1013
+ return json.dumps({"error": "Ad ID is required"}, indent=2)
1014
+
1015
+ # Coerce numeric IDs to strings (LLM clients may send integers for numeric-only IDs)
1016
+ if creative_id is not None:
1017
+ creative_id = str(creative_id)
1018
+
1019
+ params = {}
1020
+ if name is not None:
1021
+ params["name"] = name
1022
+ if status:
1023
+ params["status"] = status
1024
+ if bid_amount is not None:
1025
+ # Ensure bid_amount is sent as a string if it's not null
1026
+ params["bid_amount"] = str(bid_amount)
1027
+ if tracking_specs is not None: # Add tracking_specs to params if provided
1028
+ params["tracking_specs"] = json.dumps(tracking_specs) # Needs to be JSON encoded string
1029
+ if creative_id is not None:
1030
+ # Creative parameter needs to be a JSON object containing creative_id
1031
+ params["creative"] = json.dumps({"creative_id": creative_id})
1032
+
1033
+ if not params:
1034
+ return json.dumps({"error": "No update parameters provided (name, status, bid_amount, tracking_specs, or creative_id)"}, indent=2)
1035
+
1036
+ endpoint = f"{ad_id}"
1037
+ try:
1038
+ data = await make_api_request(endpoint, access_token, params, method='POST')
1039
+
1040
+ # Check for FLEX creative image mismatch error (3858355)
1041
+ if creative_id is not None and "error" in data:
1042
+ error_obj = data.get("error", {})
1043
+ if isinstance(error_obj, dict):
1044
+ error_details = error_obj.get("details", {})
1045
+ if isinstance(error_details, dict):
1046
+ inner_error = error_details.get("error", {})
1047
+ error_subcode = inner_error.get("error_subcode") if isinstance(inner_error, dict) else None
1048
+ else:
1049
+ error_subcode = error_obj.get("error_subcode")
1050
+ else:
1051
+ error_subcode = None
1052
+
1053
+ if error_subcode == 3858355:
1054
+ return json.dumps({
1055
+ "error": "Cannot swap creative on this ad due to FLEX image mismatch",
1056
+ "error_subcode": 3858355,
1057
+ "explanation": (
1058
+ "Meta requires the first image in the new creative's asset_feed_spec "
1059
+ "to match the image in its object_story_spec. When swapping a FLEX "
1060
+ "creative on an existing ad, this validation can fail if the new "
1061
+ "creative has different images than the original."
1062
+ ),
1063
+ "workaround": (
1064
+ "Create a new ad with the new creative instead of swapping: "
1065
+ "(1) call create_ad with the new creative_id and the same adset_id, "
1066
+ "(2) pause the old ad with update_ad(ad_id, status='PAUSED'). "
1067
+ "Note: this will lose social proof (likes, comments, shares) from the original ad."
1068
+ ),
1069
+ "ad_id": ad_id,
1070
+ "creative_id": creative_id
1071
+ }, indent=2)
1072
+
1073
+ return json.dumps(data, indent=2)
1074
+ except Exception as e:
1075
+ return json.dumps({"error": f"Failed to update ad: {str(e)}"}, indent=2)
1076
+
1077
+
1078
+ @meta_api_tool
1079
+ async def upload_ad_image(
1080
+ account_id: str,
1081
+ access_token: Optional[str] = None,
1082
+ file: Optional[str] = None,
1083
+ image_url: Optional[str] = None,
1084
+ name: Optional[str] = None
1085
+ ) -> str:
1086
+ """
1087
+ Upload an image to use in Meta Ads creatives.
1088
+
1089
+ Args:
1090
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX)
1091
+ access_token: Meta API access token (optional - will use cached token if not provided)
1092
+ file: Data URL or raw base64 string of the image (e.g., "data:image/png;base64,iVBORw0KG...")
1093
+ image_url: Direct URL to an image to fetch and upload
1094
+ name: Optional name for the image (default: filename)
1095
+
1096
+ Returns:
1097
+ JSON response with image details including hash for creative creation
1098
+ """
1099
+ # Check required parameters
1100
+ if not account_id:
1101
+ return json.dumps({"error": "No account ID provided"}, indent=2)
1102
+
1103
+ # Ensure we have image data
1104
+ if not file and not image_url:
1105
+ return json.dumps({"error": "Provide either 'file' (data URL or base64) or 'image_url'"}, indent=2)
1106
+
1107
+ account_id = ensure_act_prefix(account_id)
1108
+
1109
+ try:
1110
+ # Determine encoded_image (base64 string without data URL prefix) and a sensible name
1111
+ encoded_image: str = ""
1112
+ inferred_name: str = name or ""
1113
+
1114
+ if file:
1115
+ # Support data URL (e.g., data:image/png;base64,...) and raw base64
1116
+ data_url_prefix = "data:"
1117
+ base64_marker = "base64,"
1118
+ if file.startswith(data_url_prefix) and base64_marker in file:
1119
+ header, base64_payload = file.split(base64_marker, 1)
1120
+ encoded_image = base64_payload.strip()
1121
+
1122
+ # Infer file extension from MIME type if name not provided
1123
+ if not inferred_name:
1124
+ # Example header: data:image/png;...
1125
+ mime_type = header[len(data_url_prefix):].split(";")[0].strip()
1126
+ extension_map = {
1127
+ "image/png": ".png",
1128
+ "image/jpeg": ".jpg",
1129
+ "image/jpg": ".jpg",
1130
+ "image/webp": ".webp",
1131
+ "image/gif": ".gif",
1132
+ "image/bmp": ".bmp",
1133
+ "image/tiff": ".tiff",
1134
+ }
1135
+ ext = extension_map.get(mime_type, ".png")
1136
+ inferred_name = f"upload{ext}"
1137
+ else:
1138
+ # Assume it's already raw base64
1139
+ encoded_image = file.strip()
1140
+ if not inferred_name:
1141
+ inferred_name = "upload.png"
1142
+ else:
1143
+ # Download image from URL
1144
+ try:
1145
+ image_bytes = await try_multiple_download_methods(image_url)
1146
+ except Exception as download_error:
1147
+ return json.dumps({
1148
+ "error": "We couldn't download the image from the link provided.",
1149
+ "reason": "The server returned an error while trying to fetch the image.",
1150
+ "image_url": image_url,
1151
+ "details": str(download_error),
1152
+ "suggestions": [
1153
+ "Easiest fix: upload your image at https://pipeboard.co/creatives, then copy the image hash and use it directly instead of a URL.",
1154
+ "Make sure the link is publicly reachable (no login, VPN, or IP restrictions). Local file paths (file://...) cannot be accessed by the server.",
1155
+ "If the image is hosted on a private app or server, move it to a public URL or a CDN and try again.",
1156
+ "Verify the URL is correct and serves the actual image file."
1157
+ ]
1158
+ }, indent=2)
1159
+
1160
+ if not image_bytes:
1161
+ return json.dumps({
1162
+ "error": "We couldn't access the image at the link you provided.",
1163
+ "reason": "The image link doesn't appear to be publicly accessible or didn't return any data.",
1164
+ "image_url": image_url,
1165
+ "suggestions": [
1166
+ "Easiest fix: upload your image at https://pipeboard.co/creatives, then copy the image hash and use it directly instead of a URL.",
1167
+ "Double-check that the link is public and does not require login, VPN, or IP allow-listing. Local file paths (file://...) cannot be accessed by the server.",
1168
+ "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.",
1169
+ "Confirm the URL is correct and points directly to an image file (e.g., .jpg, .png)."
1170
+ ]
1171
+ }, indent=2)
1172
+
1173
+ import base64 # Local import
1174
+ encoded_image = base64.b64encode(image_bytes).decode("utf-8")
1175
+
1176
+ # Infer name from URL if not provided
1177
+ if not inferred_name:
1178
+ try:
1179
+ path_no_query = image_url.split("?")[0]
1180
+ filename_from_url = os.path.basename(path_no_query)
1181
+ inferred_name = filename_from_url if filename_from_url else "upload.jpg"
1182
+ except Exception:
1183
+ inferred_name = "upload.jpg"
1184
+
1185
+ # Final name resolution
1186
+ final_name = name or inferred_name or "upload.png"
1187
+
1188
+ # Prepare the API endpoint for uploading images
1189
+ endpoint = f"{account_id}/adimages"
1190
+
1191
+ # Prepare POST parameters expected by Meta API
1192
+ params = {
1193
+ "bytes": encoded_image,
1194
+ "name": final_name,
1195
+ }
1196
+
1197
+ # Make API request to upload the image
1198
+ print(f"Uploading image to Facebook Ad Account {account_id}")
1199
+ data = await make_api_request(endpoint, access_token, params, method="POST")
1200
+
1201
+ # Normalize/structure the response for callers (e.g., to easily grab image_hash)
1202
+ # Typical Graph API response shape:
1203
+ # { "images": { "<hash>": { "hash": "<hash>", "url": "...", "width": ..., "height": ..., "name": "...", "status": 1 } } }
1204
+ if isinstance(data, dict) and "images" in data and isinstance(data["images"], dict) and data["images"]:
1205
+ images_dict = data["images"]
1206
+ images_list = []
1207
+ for hash_key, info in images_dict.items():
1208
+ # Some responses may omit the nested hash, so ensure it's present
1209
+ normalized = {
1210
+ "hash": (info.get("hash") or hash_key),
1211
+ "url": info.get("url"),
1212
+ "width": info.get("width"),
1213
+ "height": info.get("height"),
1214
+ "name": info.get("name"),
1215
+ }
1216
+ # Drop null/None values
1217
+ normalized = {k: v for k, v in normalized.items() if v is not None}
1218
+ images_list.append(normalized)
1219
+
1220
+ # Sort deterministically by hash
1221
+ images_list.sort(key=lambda i: i.get("hash", ""))
1222
+ primary_hash = images_list[0].get("hash") if images_list else None
1223
+
1224
+ result = {
1225
+ "success": True,
1226
+ "account_id": account_id,
1227
+ "name": final_name,
1228
+ "image_hash": primary_hash,
1229
+ "images_count": len(images_list),
1230
+ "images": images_list
1231
+ }
1232
+ return json.dumps(result, indent=2)
1233
+
1234
+ # If the API returned an error-like structure, surface it consistently
1235
+ if isinstance(data, dict) and "error" in data:
1236
+ return json.dumps({
1237
+ "error": "Failed to upload image",
1238
+ "details": data.get("error"),
1239
+ "account_id": account_id,
1240
+ "name": final_name
1241
+ }, indent=2)
1242
+
1243
+ # Fallback: return a wrapped raw response to avoid breaking callers
1244
+ return json.dumps({
1245
+ "success": True,
1246
+ "account_id": account_id,
1247
+ "name": final_name,
1248
+ "raw_response": data
1249
+ }, indent=2)
1250
+
1251
+ except Exception as e:
1252
+ return json.dumps({
1253
+ "error": "Failed to upload image",
1254
+ "details": str(e)
1255
+ }, indent=2)
1256
+
1257
+
1258
+ # Valid image_crops keys accepted by Meta's API and their aspect ratios (width/height).
1259
+ _VALID_CROP_KEYS: list[tuple[str, int, int]] = [
1260
+ ("100x100", 100, 100), # 1:1 square -- Feed, Marketplace, Search
1261
+ ("100x72", 100, 72), # ~1.39:1 horizontal -- Marketplace, some placements
1262
+ ("400x500", 400, 500), # 4:5 portrait -- Feed on mobile, Stories fallback
1263
+ ("400x150", 400, 150), # ~2.67:1 wide banner -- Audience Network
1264
+ ("600x360", 600, 360), # ~1.67:1 horizontal -- Right column, some placements
1265
+ ("90x160", 90, 160), # 9:16 tall portrait -- Stories
1266
+ ]
1267
+ _VALID_CROP_KEY_NAMES = [k for k, _, _ in _VALID_CROP_KEYS]
1268
+
1269
+
1270
+ def _compute_crop_box(
1271
+ src_w: int, src_h: int, kw: int, kh: int
1272
+ ) -> list[list[int]]:
1273
+ """
1274
+ Compute the largest centered crop box that fits within src_wxsrc_h
1275
+ while matching the aspect ratio kw:kh.
1276
+
1277
+ Returns [[x1, y1], [x2, y2]] in pixel coordinates.
1278
+ """
1279
+ # Scale to fill the full height; check if it fits within width.
1280
+ crop_w_from_h = src_h * kw / kh
1281
+ if crop_w_from_h <= src_w:
1282
+ # Use full height; crop width centered.
1283
+ crop_w = round(crop_w_from_h)
1284
+ crop_h = src_h
1285
+ else:
1286
+ # Use full width; crop height centered.
1287
+ crop_w = src_w
1288
+ crop_h = round(src_w * kh / kw)
1289
+
1290
+ x1 = (src_w - crop_w) // 2
1291
+ y1 = (src_h - crop_h) // 2
1292
+ return [[x1, y1], [x1 + crop_w, y1 + crop_h]]
1293
+
1294
+
1295
+ async def compute_image_crops(
1296
+ image_width: int,
1297
+ image_height: int,
1298
+ crop_keys: Optional[List[str]] = None,
1299
+ ) -> str:
1300
+ """
1301
+ Compute image_crops coordinates for a source image of the given dimensions.
1302
+
1303
+ Returns the image_crops dict ready to pass directly to create_ad_creative
1304
+ or bulk_create_ad_creatives. For each crop key the result is the largest
1305
+ centered region that fits within the source image while matching the key's
1306
+ aspect ratio -- equivalent to "Original" crop (no content is cut off beyond
1307
+ what the ratio requires).
1308
+
1309
+ Args:
1310
+ image_width: Width of the source image in pixels (e.g. 1080).
1311
+ image_height: Height of the source image in pixels (e.g. 1080).
1312
+ crop_keys: Optional list of specific crop keys to compute. Defaults to
1313
+ all 6 keys accepted by Meta's API:
1314
+ "100x100" -- 1:1 square (Feed, Marketplace, Search)
1315
+ "100x72" -- ~1.39:1 horizontal (Marketplace, some placements)
1316
+ "400x500" -- 4:5 portrait (Feed on mobile, Stories fallback)
1317
+ "400x150" -- ~2.67:1 wide banner (Audience Network)
1318
+ "600x360" -- ~1.67:1 horizontal (Right column, some placements)
1319
+ "90x160" -- 9:16 tall portrait (Stories)
1320
+
1321
+ Returns:
1322
+ JSON with the image_crops dict (ready for copy-paste into create_ad_creative),
1323
+ plus validation notes for any invalid keys requested.
1324
+ """
1325
+ if image_width <= 0 or image_height <= 0:
1326
+ return json.dumps({
1327
+ "error": "image_width and image_height must be positive integers."
1328
+ }, indent=2)
1329
+
1330
+ # Resolve which keys to compute.
1331
+ if crop_keys:
1332
+ requested = crop_keys
1333
+ else:
1334
+ requested = _VALID_CROP_KEY_NAMES
1335
+
1336
+ crops: dict[str, list[list[int]]] = {}
1337
+ warnings: list[str] = []
1338
+
1339
+ key_map = {k: (kw, kh) for k, kw, kh in _VALID_CROP_KEYS}
1340
+
1341
+ for key in requested:
1342
+ if key not in key_map:
1343
+ warnings.append(
1344
+ f"'{key}' is not a valid Meta API crop key and was skipped. "
1345
+ f"Valid keys: {', '.join(_VALID_CROP_KEY_NAMES)}."
1346
+ )
1347
+ continue
1348
+ kw, kh = key_map[key]
1349
+ crops[key] = _compute_crop_box(image_width, image_height, kw, kh)
1350
+
1351
+ result: dict = {
1352
+ "image_crops": crops,
1353
+ "usage": (
1354
+ "Pass image_crops directly to create_ad_creative or as the image_crops "
1355
+ "field inside each element of bulk_create_ad_creatives."
1356
+ ),
1357
+ "source_dimensions": {"width": image_width, "height": image_height},
1358
+ }
1359
+ if warnings:
1360
+ result["warnings"] = warnings
1361
+
1362
+ return json.dumps(result, indent=2)
1363
+
1364
+
1365
+ @meta_api_tool
1366
+ async def create_ad_creative(
1367
+ account_id: str,
1368
+ image_hash: Optional[str] = None,
1369
+ access_token: Optional[str] = None,
1370
+ name: Optional[str] = None,
1371
+ page_id: Optional[Union[str, int]] = None,
1372
+ link_url: Optional[str] = None,
1373
+ message: Optional[str] = None,
1374
+ messages: Optional[List[str]] = None,
1375
+ headline: Optional[str] = None,
1376
+ headlines: Optional[List[str]] = None,
1377
+ description: Optional[str] = None,
1378
+ descriptions: Optional[List[str]] = None,
1379
+ image_hashes: Optional[List[str]] = None,
1380
+ video_id: Optional[Union[str, int]] = None,
1381
+ thumbnail_url: Optional[str] = None,
1382
+ optimization_type: Optional[str] = None,
1383
+ dynamic_creative_spec: Optional[Dict[str, Any]] = None,
1384
+ call_to_action_type: Optional[str] = None,
1385
+ lead_gen_form_id: Optional[Union[str, int]] = None,
1386
+ instagram_actor_id: Optional[str] = None,
1387
+ ad_formats: Optional[List[str]] = None,
1388
+ asset_customization_rules: Optional[List[Dict[str, Any]]] = None,
1389
+ creative_features_spec: Optional[Dict[str, Any]] = None,
1390
+ phone_number: Optional[str] = None,
1391
+ url_tags: Optional[str] = None,
1392
+ caption: Optional[str] = None,
1393
+ image_crops: Optional[Dict[str, Any]] = None,
1394
+ object_story_id: Optional[str] = None,
1395
+ disable_all_enhancements: Optional[bool] = None,
1396
+ ) -> str:
1397
+ """
1398
+ Create a new ad creative using an uploaded image hash, video ID, or an existing post.
1399
+
1400
+ Supports five creative modes:
1401
+ - **Existing post**: Provide object_story_id (format: {page_id}_{post_id}) to promote an existing
1402
+ organic or published post. No image_hash or video_id required. Optionally combine with
1403
+ asset_customization_rules to attach a 9:16 video for Story/Reels placements.
1404
+ - **Simple image/video**: Single image_hash or video_id with object_story_spec
1405
+ - **Multi-variant copy**: Use plural text params (messages[], headlines[], descriptions[]) to test
1406
+ multiple text variants with a single image/video. No optimization_type or is_dynamic_creative needed.
1407
+ - **Dynamic Creative**: Multiple variants with dynamic_creative_spec (requires is_dynamic_creative on ad set)
1408
+ - **FLEX/DOF (Advantage+)**: Set optimization_type="DEGREES_OF_FREEDOM" for Meta to auto-optimize
1409
+ across all asset combinations without requiring is_dynamic_creative on the ad set
1410
+
1411
+ Args:
1412
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX)
1413
+ image_hash: Hash of a single uploaded image (cannot be used with image_hashes or video_id)
1414
+ access_token: Meta API access token (optional - will use cached token if not provided)
1415
+ name: Creative name
1416
+ page_id: Facebook Page ID (string or int; coerced to string)
1417
+ link_url: Destination URL for the ad (required unless using lead_gen_form_id)
1418
+ message: Single ad copy/text (cannot be used with messages)
1419
+ messages: List of primary text variants for multi-variant copy testing (cannot be used with message)
1420
+ headline: Single headline for simple ads (cannot be used with headlines)
1421
+ headlines: List of headline variants for multi-variant copy testing (cannot be used with headline)
1422
+ description: Single description for simple ads (cannot be used with descriptions)
1423
+ descriptions: List of description variants for multi-variant copy testing (cannot be used with description)
1424
+ image_hashes: List of image hashes for FLEX creatives (up to 10, cannot be used with image_hash or video_id)
1425
+ video_id: Meta video ID for video creatives (cannot be used with image_hash or image_hashes).
1426
+ Upload a video first via the Meta API, then use the returned video ID here.
1427
+ thumbnail_url: Thumbnail image URL for video creatives. Recommended when using video_id.
1428
+ Meta will auto-generate a thumbnail if not provided.
1429
+ optimization_type: Optional. Set to "DEGREES_OF_FREEDOM" for FLEX (Advantage+) creatives that
1430
+ allow Meta to auto-optimize across all asset combinations. Not required for
1431
+ text-only multi-variant creatives (messages[], headlines[], descriptions[]
1432
+ work without it). When using DEGREES_OF_FREEDOM, at least one asset field
1433
+ (image_hashes, messages, headlines, or descriptions) must contain more than
1434
+ one variant.
1435
+ NOTE: If asset_customization_rules is also provided, optimization_type
1436
+ is automatically removed because Meta ignores placement rules for DOF
1437
+ creatives. The creative will use regular dynamic creative mode instead.
1438
+ dynamic_creative_spec: Dynamic creative optimization settings
1439
+ call_to_action_type: Call to action button type (e.g., 'LEARN_MORE', 'SIGN_UP', 'SHOP_NOW',
1440
+ 'CALL_NOW'). When using CALL_NOW, also provide phone_number.
1441
+ lead_gen_form_id: Lead generation form ID for lead generation campaigns. Required when using
1442
+ lead generation CTAs like 'SIGN_UP', 'GET_OFFER', 'SUBSCRIBE', etc.
1443
+ instagram_actor_id: Instagram account ID for Instagram placements (must be a string
1444
+ to avoid JavaScript integer precision loss for IDs exceeding
1445
+ Number.MAX_SAFE_INTEGER). Sent as instagram_user_id inside
1446
+ object_story_spec (Meta deprecated instagram_actor_id in Jan 2026).
1447
+ ad_formats: List of ad format strings for asset_feed_spec (e.g., ["AUTOMATIC_FORMAT"] for
1448
+ Flexible ads, ["SINGLE_IMAGE"] for single image, ["SINGLE_VIDEO"] for video).
1449
+ When optimization_type is "DEGREES_OF_FREEDOM" with image_hashes, defaults to
1450
+ ["AUTOMATIC_FORMAT"] (Flexible format). For video creatives, defaults to
1451
+ ["SINGLE_VIDEO"]. Otherwise defaults to ["SINGLE_IMAGE"].
1452
+ asset_customization_rules: List of placement-specific asset overrides for asset_feed_spec.
1453
+ phone_number: Phone number for CALL_NOW call-to-action ads (click-to-call).
1454
+ Required when call_to_action_type is CALL_NOW. Use E.164 format
1455
+ (e.g., "+18005551234"). The number is passed to Meta in
1456
+ call_to_action.value.phone_number. Common use case: geo-routed
1457
+ call ads with different phone numbers per ad set.
1458
+ creative_features_spec: Advantage+ Creative feature opt-ins/opt-outs. Controls individual
1459
+ creative enhancements like image_touchups, text_optimizations, inline_comment,
1460
+ add_text_overlay, music, 3d_animation, etc. Each feature is a dict with
1461
+ "enroll_status" set to "OPT_IN" or "OPT_OUT".
1462
+ Example: {"image_touchups": {"enroll_status": "OPT_IN"},
1463
+ "inline_comment": {"enroll_status": "OPT_IN"}}
1464
+ Sent to Meta as degrees_of_freedom_spec.creative_features_spec.
1465
+ url_tags: URL tracking parameters appended to the destination URL (e.g.,
1466
+ "utm_source=facebook&utm_medium=cpc&utm_campaign=spring_sale").
1467
+ Sets the url_tags field on the creative.
1468
+ caption: Display URL shown in the ad (e.g., "example.com/shoes"). Sets the
1469
+ caption field in link_data. If not provided, Meta auto-generates it
1470
+ from the destination URL. Only applies to image (link_data) creatives.
1471
+ image_crops: Crop coordinates for different aspect ratios. Applied in link_data for
1472
+ image creatives.
1473
+
1474
+ Use the compute_image_crops tool first to get the correct coordinates
1475
+ for your specific image dimensions -- it computes centered crop boxes
1476
+ for any source size automatically.
1477
+
1478
+ Valid crop keys (only these 6 are accepted by Meta's API):
1479
+ "100x100" -- 1:1 square (Feed, Marketplace, Search)
1480
+ "100x72" -- ~1.39:1 horizontal (Marketplace, some placements)
1481
+ "400x500" -- 4:5 portrait (Feed on mobile, Stories fallback)
1482
+ "400x150" -- ~2.67:1 wide banner (Audience Network)
1483
+ "600x360" -- ~1.67:1 horizontal (Right column, some placements)
1484
+ "90x160" -- 9:16 tall portrait (Stories)
1485
+
1486
+ Format: {"100x100": [[x1,y1],[x2,y2]], "400x500": [[x1,y1],[x2,y2]]}
1487
+ Coordinates are pixel-based (top-left and bottom-right corners).
1488
+ The bounding box aspect ratio must match the key ratio as closely as possible.
1489
+ Image origin (0,0) is the upper-left corner.
1490
+
1491
+ Omit to let Meta auto-crop (default for horizontal is 1.91:1 recommended).
1492
+ object_story_id: ID of an existing organic or published Facebook/Instagram post to promote
1493
+ as an ad. Format: "{page_id}_{post_id}" (e.g., "124965744226834_3888007311337206").
1494
+ When provided, image_hash and video_id are not required. page_id is also not
1495
+ required (it is encoded in the story ID). Combine with asset_customization_rules
1496
+ to attach a 9:16 video for Story/Reels placements while the organic post
1497
+ serves as the feed creative -- a common "Use Existing Post" workflow.
1498
+ Example: object_story_id="124965744226834_3888007311337206",
1499
+ asset_customization_rules=[{"placement_groups": ["STORY"],
1500
+ "customization_spec": {"video_ids": ["890310874031162"]}}]
1501
+ disable_all_enhancements: When True, opts out of all Advantage+ Creative enhancements by
1502
+ setting every known creative_features_spec key (image_touchups,
1503
+ text_optimizations, video_auto_crop, etc.) to OPT_OUT and also
1504
+ disabling contextual_multi_ads. Use when you want full creative
1505
+ control without Meta's auto-modifications.
1506
+ asset_customization_rules: Lets you assign different images or videos to specific placement groups
1507
+ (e.g., feed vs. stories). Only valid with image_hashes or plural asset params.
1508
+ Each rule uses a user-friendly format that is automatically translated to
1509
+ Meta's API format (adlabels + customization_spec positions):
1510
+ - placement_groups: list of placement group names
1511
+ Valid values: FEED, STORY, MESSENGER, INSTREAM_VIDEO, SEARCH, SHOP,
1512
+ AUDIENCE_NETWORK
1513
+ - customization_spec: dict specifying the asset to use for those placements
1514
+ Supported keys: image_hashes (list), video_ids (list),
1515
+ bodies, titles, descriptions (text overrides)
1516
+ All image hashes referenced in rules must also be in image_hashes.
1517
+ Example (feed gets one image, stories gets another):
1518
+ [
1519
+ {"placement_groups": ["FEED"],
1520
+ "customization_spec": {"image_hashes": ["<feed_hash>"]}},
1521
+ {"placement_groups": ["STORY"],
1522
+ "customization_spec": {"image_hashes": ["<story_hash>"]}}
1523
+ ]
1524
+
1525
+ Returns:
1526
+ JSON response with created creative details
1527
+ """
1528
+ # Check required parameters
1529
+ if not account_id:
1530
+ return json.dumps({"error": "No account ID provided"}, indent=2)
1531
+
1532
+ # Coerce numeric IDs to strings (LLM clients may send integers for numeric-only IDs)
1533
+ if video_id is not None:
1534
+ video_id = str(video_id)
1535
+ if instagram_actor_id is not None:
1536
+ instagram_actor_id = str(instagram_actor_id).strip('"').strip("'")
1537
+ if lead_gen_form_id is not None:
1538
+ lead_gen_form_id = str(lead_gen_form_id)
1539
+
1540
+ # Defensive coercion: some MCP transports deliver array/dict params as JSON strings
1541
+ if isinstance(asset_customization_rules, str):
1542
+ try:
1543
+ _parsed = json.loads(asset_customization_rules)
1544
+ if isinstance(_parsed, list):
1545
+ asset_customization_rules = _parsed
1546
+ except (json.JSONDecodeError, TypeError):
1547
+ pass
1548
+
1549
+ if isinstance(creative_features_spec, str):
1550
+ try:
1551
+ _parsed = json.loads(creative_features_spec)
1552
+ if isinstance(_parsed, dict):
1553
+ creative_features_spec = _parsed
1554
+ except (json.JSONDecodeError, TypeError):
1555
+ pass
1556
+
1557
+ if isinstance(image_crops, str):
1558
+ try:
1559
+ _parsed = json.loads(image_crops)
1560
+ if isinstance(_parsed, dict):
1561
+ image_crops = _parsed
1562
+ except (json.JSONDecodeError, TypeError):
1563
+ pass
1564
+
1565
+ for _param_name, _param_val in [
1566
+ ('image_hashes', image_hashes),
1567
+ ('messages', messages),
1568
+ ('headlines', headlines),
1569
+ ('descriptions', descriptions),
1570
+ ('ad_formats', ad_formats),
1571
+ ]:
1572
+ if isinstance(_param_val, str):
1573
+ try:
1574
+ _parsed = json.loads(_param_val)
1575
+ if isinstance(_parsed, list):
1576
+ if _param_name == 'image_hashes':
1577
+ image_hashes = _parsed
1578
+ elif _param_name == 'messages':
1579
+ messages = _parsed
1580
+ elif _param_name == 'headlines':
1581
+ headlines = _parsed
1582
+ elif _param_name == 'descriptions':
1583
+ descriptions = _parsed
1584
+ elif _param_name == 'ad_formats':
1585
+ ad_formats = _parsed
1586
+ except (json.JSONDecodeError, TypeError):
1587
+ pass
1588
+
1589
+ logger.debug(
1590
+ "create_ad_creative called: image_hash=%s, image_hashes=%s(%s), video_id=%s, "
1591
+ "messages=%s, headlines=%s, descriptions=%s, optimization_type=%s",
1592
+ type(image_hash).__name__,
1593
+ type(image_hashes).__name__, image_hashes,
1594
+ video_id,
1595
+ type(messages).__name__,
1596
+ type(headlines).__name__,
1597
+ type(descriptions).__name__,
1598
+ optimization_type,
1599
+ )
1600
+
1601
+ # Validate media mutual exclusivity: exactly one of image_hash, image_hashes, or video_id
1602
+ # (object_story_id is an alternative media source -- it references an existing post)
1603
+ media_params = sum(1 for x in [image_hash, image_hashes, video_id] if x)
1604
+ if media_params > 1:
1605
+ return json.dumps({"error": "Only one media source allowed. Use 'image_hash' for a single image, 'image_hashes' for multiple images, or 'video_id' for video."}, indent=2)
1606
+
1607
+ if media_params == 0 and not object_story_id:
1608
+ return json.dumps({"error": "No media provided. Specify 'image_hash' for a single image, 'image_hashes' for multiple images, 'video_id' for a video, or 'object_story_id' to promote an existing post (format: {page_id}_{post_id})."}, indent=2)
1609
+
1610
+ # Validate image_hashes limits
1611
+ if image_hashes:
1612
+ if len(image_hashes) > 10:
1613
+ return json.dumps({"error": "Maximum 10 image hashes allowed for FLEX creatives"}, indent=2)
1614
+
1615
+ # Validate thumbnail_url only with video_id
1616
+ if thumbnail_url and not video_id:
1617
+ return json.dumps({"error": "thumbnail_url can only be used with video_id"}, indent=2)
1618
+
1619
+ # Validate optimization_type
1620
+ if optimization_type and optimization_type != "DEGREES_OF_FREEDOM":
1621
+ return json.dumps({"error": f"Invalid optimization_type '{optimization_type}'. Only 'DEGREES_OF_FREEDOM' is supported."}, indent=2)
1622
+
1623
+ # Validate message / messages mutual exclusivity
1624
+ if message and messages:
1625
+ return json.dumps({"error": "Cannot specify both 'message' and 'messages'. Use 'message' for single text or 'messages' for multiple variants."}, indent=2)
1626
+
1627
+ if not link_url and not lead_gen_form_id and not object_story_id:
1628
+ return json.dumps({"error": "No link_url provided. A destination URL is required for ad creatives (unless using lead_gen_form_id or object_story_id)."}, indent=2)
1629
+
1630
+ if not name:
1631
+ name = f"Creative {int(time.time())}"
1632
+
1633
+ account_id = ensure_act_prefix(account_id)
1634
+
1635
+ # Enhanced page discovery: If no page ID is provided, use robust discovery methods.
1636
+ # Skip when object_story_id is provided -- the page is embedded in the story ID format.
1637
+ if not page_id and not object_story_id:
1638
+ try:
1639
+ # Use the comprehensive page discovery logic from get_account_pages
1640
+ page_discovery_result = await _discover_pages_for_account(account_id, access_token)
1641
+
1642
+ if page_discovery_result.get("success"):
1643
+ page_id = page_discovery_result["page_id"]
1644
+ page_name = page_discovery_result.get("page_name", "Unknown")
1645
+ print(f"Auto-discovered page ID: {page_id} ({page_name})")
1646
+ else:
1647
+ return json.dumps({
1648
+ "error": "No page ID provided and no suitable pages found for this account",
1649
+ "details": page_discovery_result.get("message", "Page discovery failed"),
1650
+ "suggestions": [
1651
+ "Use get_account_pages to see available pages",
1652
+ "Use search_pages_by_name to find specific pages",
1653
+ "Provide a page_id parameter manually"
1654
+ ]
1655
+ }, indent=2)
1656
+ except Exception as e:
1657
+ return json.dumps({
1658
+ "error": "Error during page discovery",
1659
+ "details": str(e),
1660
+ "suggestion": "Please provide a page_id parameter or use get_account_pages to find available pages"
1661
+ }, indent=2)
1662
+
1663
+ # Normalize page_id to string after all assignment paths (input param + discovery).
1664
+ # Skip when object_story_id is used -- page_id may be None in that path.
1665
+ if page_id is not None:
1666
+ page_id = str(page_id)
1667
+
1668
+ # Validate headline/description parameters - cannot mix simple and complex
1669
+ if headline and headlines:
1670
+ return json.dumps({"error": "Cannot specify both 'headline' and 'headlines'. Use 'headline' for single headline or 'headlines' for multiple."}, indent=2)
1671
+
1672
+ if description and descriptions:
1673
+ return json.dumps({"error": "Cannot specify both 'description' and 'descriptions'. Use 'description' for single description or 'descriptions' for multiple."}, indent=2)
1674
+
1675
+ # Validate dynamic creative parameters (plural forms only)
1676
+ if headlines:
1677
+ if len(headlines) > 5:
1678
+ return json.dumps({"error": "Maximum 5 headlines allowed for dynamic creatives"}, indent=2)
1679
+ for i, h in enumerate(headlines):
1680
+ if len(h) > 40:
1681
+ return json.dumps({"error": f"Headline {i+1} exceeds 40 character limit"}, indent=2)
1682
+
1683
+ if descriptions:
1684
+ if len(descriptions) > 5:
1685
+ return json.dumps({"error": "Maximum 5 descriptions allowed for dynamic creatives"}, indent=2)
1686
+ for i, d in enumerate(descriptions):
1687
+ if len(d) > 125:
1688
+ return json.dumps({"error": f"Description {i+1} exceeds 125 character limit"}, indent=2)
1689
+
1690
+ # Prepare the API endpoint for creating a creative
1691
+ endpoint = f"{account_id}/adcreatives"
1692
+
1693
+ try:
1694
+ # Prepare the creative data
1695
+ creative_data = {
1696
+ "name": name
1697
+ }
1698
+
1699
+ # Auto-downgrade DOF when asset_customization_rules is provided.
1700
+ # Meta silently ignores asset_customization_rules for DEGREES_OF_FREEDOM
1701
+ # creatives (confirmed by e2e testing). Dropping optimization_type lets the
1702
+ # rules take effect under regular dynamic creative mode instead.
1703
+ dof_downgraded = False
1704
+ if optimization_type and asset_customization_rules:
1705
+ logger.info(
1706
+ "Dropping optimization_type=%s because asset_customization_rules is set "
1707
+ "(Meta ignores placement rules for DOF creatives)",
1708
+ optimization_type,
1709
+ )
1710
+ optimization_type = None
1711
+ dof_downgraded = True
1712
+
1713
+ # Determine whether to use asset_feed_spec path:
1714
+ # - plural parameters (headlines/descriptions/messages/image_hashes), OR
1715
+ # - optimization_type is set (FLEX creatives always use asset_feed_spec), OR
1716
+ # - asset_customization_rules requires asset_feed_spec, OR
1717
+ # - video_id + description: Meta's video_data rejects "description" directly,
1718
+ # so route through asset_feed_spec which supports descriptions for video ads
1719
+ use_asset_feed = bool(
1720
+ headlines or descriptions or messages or image_hashes or optimization_type
1721
+ or asset_customization_rules or (video_id and description)
1722
+ )
1723
+
1724
+ # Track if this is a video creative
1725
+ is_video = bool(video_id)
1726
+
1727
+ # Meta API v24 REQUIRES a thumbnail (image_hash or image_url) in video_data.
1728
+ # If the caller didn't provide one, auto-fetch from the video object.
1729
+ if is_video and not thumbnail_url:
1730
+ try:
1731
+ video_info = await make_api_request(
1732
+ video_id, access_token, {"fields": "picture"}
1733
+ )
1734
+ if isinstance(video_info, dict) and "picture" in video_info:
1735
+ thumbnail_url = video_info["picture"]
1736
+ logger.info(f"Auto-fetched video thumbnail: {thumbnail_url[:80]}...")
1737
+ else:
1738
+ logger.warning(f"Could not auto-fetch thumbnail for video {video_id}: {video_info}")
1739
+ except Exception as e:
1740
+ logger.warning(f"Failed to auto-fetch thumbnail for video {video_id}: {e}")
1741
+
1742
+ if object_story_id:
1743
+ # ---------------------------------------------------------------------------
1744
+ # Existing-post (object_story_id) path: promote an organic/published post
1745
+ # ---------------------------------------------------------------------------
1746
+ creative_data["object_story_id"] = object_story_id
1747
+
1748
+ if asset_customization_rules:
1749
+ # Build asset_feed_spec with placement-specific video overrides
1750
+ # (e.g., a 9:16 video for Story/Reels while the post shows in feed)
1751
+ translated_rules_osi, videos_array_osi = _translate_video_customization_rules_for_existing_post(
1752
+ asset_customization_rules
1753
+ )
1754
+ asset_feed_spec_osi: Dict[str, Any] = {}
1755
+ if videos_array_osi:
1756
+ asset_feed_spec_osi["videos"] = videos_array_osi
1757
+ if translated_rules_osi:
1758
+ asset_feed_spec_osi["asset_customization_rules"] = translated_rules_osi
1759
+ if link_url:
1760
+ asset_feed_spec_osi["link_urls"] = [{"website_url": link_url}]
1761
+ if call_to_action_type:
1762
+ asset_feed_spec_osi["call_to_action_types"] = [call_to_action_type]
1763
+ if asset_feed_spec_osi:
1764
+ creative_data["asset_feed_spec"] = asset_feed_spec_osi
1765
+ elif call_to_action_type:
1766
+ # No asset_feed_spec: put CTA at top level for simple existing-post creatives
1767
+ cta_osi: Dict[str, Any] = {"type": call_to_action_type}
1768
+ cta_osi_value: Dict[str, Any] = {}
1769
+ if link_url:
1770
+ cta_osi_value["link"] = link_url
1771
+ if lead_gen_form_id:
1772
+ cta_osi_value["lead_gen_form_id"] = lead_gen_form_id
1773
+ if phone_number:
1774
+ cta_osi_value["phone_number"] = phone_number
1775
+ if cta_osi_value:
1776
+ cta_osi["value"] = cta_osi_value
1777
+ creative_data["call_to_action"] = cta_osi
1778
+
1779
+ if instagram_actor_id:
1780
+ creative_data["instagram_actor_id"] = instagram_actor_id
1781
+
1782
+ elif use_asset_feed:
1783
+ # Build the media array from the provided source
1784
+ if is_video:
1785
+ # Video in asset_feed_spec uses "videos" key
1786
+ videos_array = [{"video_id": video_id}]
1787
+ if thumbnail_url:
1788
+ videos_array[0]["thumbnail_url"] = thumbnail_url
1789
+ elif image_hashes:
1790
+ images_array = [{"hash": h} for h in image_hashes]
1791
+ else:
1792
+ images_array = [{"hash": image_hash}]
1793
+
1794
+ # Translate placement_groups-style asset_customization_rules to Meta API format.
1795
+ # Meta API uses customization_spec for placement selection (publisher_platforms,
1796
+ # facebook_positions, instagram_positions) and image_label/video_label at the
1797
+ # rule level for asset selection. Images also need adlabels assigned.
1798
+ if asset_customization_rules and not is_video:
1799
+ asset_customization_rules, images_array = _translate_asset_customization_rules(
1800
+ asset_customization_rules, images_array
1801
+ )
1802
+
1803
+ # ------------------------------------------------------------------
1804
+ # Build asset_feed_spec base: DOF vs non-DOF use different patterns.
1805
+ #
1806
+ # DOF (DEGREES_OF_FREEDOM / FLEX / Advantage+):
1807
+ # asset_feed_spec has ONLY: media, optimization_type, text variants.
1808
+ # URL, ad_formats, and CTA go in object_story_spec.link_data.
1809
+ # This matches the working Next.js duplication pattern -- Meta's
1810
+ # own GET response omits link_urls/ad_formats/call_to_action_types
1811
+ # from asset_feed_spec, and the duplication passes it through AS-IS.
1812
+ # Including those fields causes Meta to silently ignore
1813
+ # asset_feed_spec for multi-image creatives.
1814
+ #
1815
+ # Non-DOF (regular Dynamic Creative):
1816
+ # asset_feed_spec includes link_urls, ad_formats, call_to_action_types
1817
+ # as before (this path is verified working).
1818
+ # ------------------------------------------------------------------
1819
+ if optimization_type:
1820
+ asset_feed_spec = {"optimization_type": optimization_type}
1821
+ # Only include ad_formats if explicitly provided by the caller
1822
+ if ad_formats:
1823
+ asset_feed_spec["ad_formats"] = ad_formats
1824
+ else:
1825
+ resolved_ad_formats = ad_formats or (["SINGLE_VIDEO"] if is_video else ["SINGLE_IMAGE"])
1826
+ asset_feed_spec = {
1827
+ "link_urls": [{"website_url": link_url}],
1828
+ "ad_formats": resolved_ad_formats,
1829
+ }
1830
+
1831
+ # Add media to asset_feed_spec (shared by both paths)
1832
+ if is_video:
1833
+ asset_feed_spec["videos"] = videos_array
1834
+ else:
1835
+ asset_feed_spec["images"] = images_array
1836
+
1837
+ # Handle headlines - Meta API uses "titles" not "headlines" in asset_feed_spec
1838
+ # Auto-promote singular headline to single-element array when in asset_feed_spec path
1839
+ if headlines:
1840
+ asset_feed_spec["titles"] = [{"text": headline_text} for headline_text in headlines]
1841
+ elif headline:
1842
+ asset_feed_spec["titles"] = [{"text": headline}]
1843
+
1844
+ # Handle descriptions
1845
+ # Auto-promote singular description to single-element array when in asset_feed_spec path
1846
+ if descriptions:
1847
+ asset_feed_spec["descriptions"] = [{"text": description_text} for description_text in descriptions]
1848
+ elif description:
1849
+ asset_feed_spec["descriptions"] = [{"text": description}]
1850
+
1851
+ # Handle bodies: messages (plural) or message (singular)
1852
+ if messages:
1853
+ asset_feed_spec["bodies"] = [{"text": m} for m in messages]
1854
+ elif message:
1855
+ asset_feed_spec["bodies"] = [{"text": message}]
1856
+
1857
+ # CTA in asset_feed_spec only for non-DOF (DOF puts CTA in link_data)
1858
+ if call_to_action_type and not optimization_type:
1859
+ asset_feed_spec["call_to_action_types"] = [call_to_action_type]
1860
+
1861
+ # Add placement-specific asset customization rules if provided
1862
+ if asset_customization_rules:
1863
+ asset_feed_spec["asset_customization_rules"] = asset_customization_rules
1864
+
1865
+ creative_data["asset_feed_spec"] = asset_feed_spec
1866
+
1867
+ # ------------------------------------------------------------------
1868
+ # Build object_story_spec for asset_feed_spec creatives.
1869
+ # Meta rejects bare page_id (error 2061015) -- needs a link anchor.
1870
+ # ------------------------------------------------------------------
1871
+ if is_video:
1872
+ # Video FLEX: use video_data with call_to_action carrying
1873
+ # the link URL. This is required for Meta to associate the
1874
+ # video and destination URL with the creative.
1875
+ video_anchor = {"video_id": video_id}
1876
+ if thumbnail_url:
1877
+ video_anchor["image_url"] = thumbnail_url
1878
+ cta_type = call_to_action_type or "LEARN_MORE"
1879
+ cta_value = {}
1880
+ if link_url:
1881
+ cta_value["link"] = link_url
1882
+ if lead_gen_form_id:
1883
+ cta_value["lead_gen_form_id"] = lead_gen_form_id
1884
+ if phone_number:
1885
+ cta_value["phone_number"] = phone_number
1886
+ cta_data = {"type": cta_type}
1887
+ if cta_value:
1888
+ cta_data["value"] = cta_value
1889
+ video_anchor["call_to_action"] = cta_data
1890
+ creative_data["object_story_spec"] = {
1891
+ "page_id": page_id,
1892
+ "video_data": video_anchor
1893
+ }
1894
+ else:
1895
+ if not optimization_type:
1896
+ # Non-DOF asset_feed_spec: Meta requires bare
1897
+ # object_story_spec (no link_data). URLs, images, CTA
1898
+ # live exclusively in asset_feed_spec.
1899
+ # Ref: developers.facebook.com/docs/marketing-api/asset-customization-rules
1900
+ creative_data["object_story_spec"] = {
1901
+ "page_id": page_id,
1902
+ }
1903
+ else:
1904
+ # DOF: link_data serves as the "anchor" creative template.
1905
+ link_data = {"link": link_url}
1906
+ if image_hashes:
1907
+ link_data["image_hash"] = image_hashes[0]
1908
+ elif image_hash:
1909
+ link_data["image_hash"] = image_hash
1910
+ if caption:
1911
+ link_data["caption"] = caption
1912
+ if image_crops:
1913
+ link_data["image_crops"] = image_crops
1914
+ if call_to_action_type:
1915
+ cta = {"type": call_to_action_type}
1916
+ cta_value = {}
1917
+ if link_url:
1918
+ cta_value["link"] = link_url
1919
+ if lead_gen_form_id:
1920
+ cta_value["lead_gen_form_id"] = lead_gen_form_id
1921
+ if phone_number:
1922
+ cta_value["phone_number"] = phone_number
1923
+ if cta_value:
1924
+ cta["value"] = cta_value
1925
+ link_data["call_to_action"] = cta
1926
+ creative_data["object_story_spec"] = {
1927
+ "page_id": page_id,
1928
+ "link_data": link_data,
1929
+ }
1930
+ else:
1931
+ if is_video:
1932
+ # Use object_story_spec with video_data for simple video creatives.
1933
+ # NOTE: video_data does NOT support a "link" field directly.
1934
+ # The destination URL goes in call_to_action.value.link.
1935
+ # Thumbnail auto-fetch is handled earlier (before use_asset_feed branch).
1936
+ video_data = {
1937
+ "video_id": video_id,
1938
+ }
1939
+
1940
+ if thumbnail_url:
1941
+ video_data["image_url"] = thumbnail_url
1942
+
1943
+ if message:
1944
+ video_data["message"] = message
1945
+
1946
+ if headline:
1947
+ video_data["title"] = headline
1948
+
1949
+ # NOTE: Meta API v24 rejects "description" in video_data AND
1950
+ # "link_description" in call_to_action.value (deprecated).
1951
+ # Description is not settable for simple video creatives.
1952
+
1953
+ # Build call_to_action with the destination URL.
1954
+ # For video creatives, link_url MUST go in call_to_action.value.link
1955
+ # (not as a top-level field in video_data).
1956
+ cta_value = {}
1957
+ if link_url:
1958
+ cta_value["link"] = link_url
1959
+ if lead_gen_form_id:
1960
+ cta_value["lead_gen_form_id"] = lead_gen_form_id
1961
+ if phone_number:
1962
+ cta_value["phone_number"] = phone_number
1963
+ cta_type = call_to_action_type or ("LEARN_MORE" if link_url else None)
1964
+ if cta_type:
1965
+ cta_data = {"type": cta_type}
1966
+ if cta_value:
1967
+ cta_data["value"] = cta_value
1968
+ video_data["call_to_action"] = cta_data
1969
+
1970
+ creative_data["object_story_spec"] = {
1971
+ "page_id": page_id,
1972
+ "video_data": video_data
1973
+ }
1974
+ else:
1975
+ # Use traditional object_story_spec with link_data for simple image creatives
1976
+ creative_data["object_story_spec"] = {
1977
+ "page_id": page_id,
1978
+ "link_data": {
1979
+ "image_hash": image_hash,
1980
+ "link": link_url
1981
+ }
1982
+ }
1983
+
1984
+ # Add optional parameters if provided
1985
+ if message:
1986
+ creative_data["object_story_spec"]["link_data"]["message"] = message
1987
+
1988
+ # Add headline (singular) to link_data
1989
+ if headline:
1990
+ creative_data["object_story_spec"]["link_data"]["name"] = headline
1991
+
1992
+ # Add description (singular) to link_data
1993
+ if description:
1994
+ creative_data["object_story_spec"]["link_data"]["description"] = description
1995
+
1996
+ # Add caption (display URL) to link_data
1997
+ if caption:
1998
+ creative_data["object_story_spec"]["link_data"]["caption"] = caption
1999
+
2000
+ # Add image crops to link_data for placement-specific cropping
2001
+ if image_crops:
2002
+ creative_data["object_story_spec"]["link_data"]["image_crops"] = image_crops
2003
+
2004
+ # Add call_to_action to link_data for simple creatives
2005
+ if call_to_action_type:
2006
+ cta_data = {"type": call_to_action_type}
2007
+ cta_value = {}
2008
+
2009
+ # Add lead form ID to value object if provided (required for lead generation campaigns)
2010
+ if lead_gen_form_id:
2011
+ cta_value["lead_gen_form_id"] = lead_gen_form_id
2012
+ if phone_number:
2013
+ cta_value["phone_number"] = phone_number
2014
+ if cta_value:
2015
+ cta_data["value"] = cta_value
2016
+
2017
+ creative_data["object_story_spec"]["link_data"]["call_to_action"] = cta_data
2018
+
2019
+ # Add dynamic creative spec if provided
2020
+ if dynamic_creative_spec:
2021
+ creative_data["dynamic_creative_spec"] = dynamic_creative_spec
2022
+
2023
+ # Add Advantage+ Creative feature opt-ins if provided.
2024
+ # Only sent when the user explicitly passes creative_features_spec.
2025
+ if creative_features_spec:
2026
+ creative_data["degrees_of_freedom_spec"] = {
2027
+ "creative_features_spec": creative_features_spec
2028
+ }
2029
+
2030
+ # Opt out of all Advantage+ Creative enhancements when requested.
2031
+ # Sets every known individual creative_features_spec key to OPT_OUT and
2032
+ # disables contextual_multi_ads. The legacy "standard_enhancements" key
2033
+ # is deprecated for POST operations (Meta error subcode 3858504), so we
2034
+ # enumerate each key explicitly -- matching the TS expandDisableAllEnhancements().
2035
+ if disable_all_enhancements:
2036
+ dof = creative_data.setdefault("degrees_of_freedom_spec", {})
2037
+ cfs = dof.setdefault("creative_features_spec", {})
2038
+ for key in _ALL_ENHANCEMENT_KEYS:
2039
+ if key not in cfs:
2040
+ cfs[key] = {"enroll_status": "OPT_OUT"}
2041
+ if "contextual_multi_ads" not in creative_data:
2042
+ creative_data["contextual_multi_ads"] = {"enroll_status": "OPT_OUT"}
2043
+
2044
+ # Add URL tracking parameters if provided.
2045
+ if url_tags:
2046
+ creative_data["url_tags"] = url_tags
2047
+
2048
+ # instagram_actor_id -> instagram_user_id migration (Jan 2026).
2049
+ # Meta deprecated instagram_actor_id; the replacement is instagram_user_id
2050
+ # inside object_story_spec (sibling of page_id and video_data/link_data).
2051
+ if instagram_actor_id and "object_story_spec" in creative_data:
2052
+ creative_data["object_story_spec"]["instagram_user_id"] = instagram_actor_id
2053
+
2054
+ # Make API request to create the creative
2055
+ data = await make_api_request(endpoint, access_token, creative_data, method="POST")
2056
+
2057
+ # Check for instagram_actor_id / instagram_user_id permission errors.
2058
+ # This happens when the user's Meta access token lacks the instagram_basic
2059
+ # permission. Re-connecting the Facebook account refreshes the token.
2060
+ if instagram_actor_id and "error" in data:
2061
+ err_details = data.get("error", {}).get("details", {})
2062
+ inner_msg = ""
2063
+ if isinstance(err_details, dict):
2064
+ inner_err = err_details.get("error", {})
2065
+ if isinstance(inner_err, dict):
2066
+ inner_msg = inner_err.get("message", "")
2067
+ if "valid Instagram account id" in inner_msg or "instagram_actor_id" in inner_msg.lower():
2068
+ return json.dumps({
2069
+ "error": "Instagram account not authorized for advertising",
2070
+ "explanation": (
2071
+ "The Meta API rejected the Instagram account ID. This usually means "
2072
+ "your Facebook access token is missing the 'instagram_basic' permission, "
2073
+ "which is required to use Instagram placements in ad creatives."
2074
+ ),
2075
+ "fix": (
2076
+ "Reconnect your Facebook account at https://pipeboard.co/connections "
2077
+ "to refresh your access token with the required permissions."
2078
+ ),
2079
+ "instagram_actor_id": instagram_actor_id,
2080
+ "meta_error": inner_msg
2081
+ }, indent=2)
2082
+
2083
+ # If successful, get more details about the created creative
2084
+ if "id" in data:
2085
+ creative_id = data["id"]
2086
+ creative_endpoint = f"{creative_id}"
2087
+ creative_params = {
2088
+ "fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,object_type,body,title,effective_object_story_id,asset_feed_spec{images,videos,bodies,titles,descriptions,link_urls,ad_formats,call_to_action_types,optimization_type,asset_customization_rules},url_tags,link_url"
2089
+ }
2090
+
2091
+ creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
2092
+ result = {
2093
+ "success": True,
2094
+ "creative_id": creative_id,
2095
+ "details": creative_details,
2096
+ }
2097
+ if dof_downgraded:
2098
+ result["warning"] = (
2099
+ "optimization_type DEGREES_OF_FREEDOM was automatically removed because "
2100
+ "asset_customization_rules was provided. Meta silently ignores placement "
2101
+ "rules for DOF creatives. The creative was created using regular dynamic "
2102
+ "creative mode so placement-specific images are respected. To use DOF "
2103
+ "instead, remove asset_customization_rules."
2104
+ )
2105
+ return json.dumps(result, indent=2)
2106
+
2107
+ return json.dumps(data, indent=2)
2108
+
2109
+ except Exception as e:
2110
+ logger.exception("create_ad_creative failed")
2111
+ return json.dumps({
2112
+ "error": "Failed to create ad creative",
2113
+ "details": str(e)
2114
+ }, indent=2)
2115
+
2116
+
2117
+ @meta_api_tool
2118
+ async def update_ad_creative(
2119
+ creative_id: str,
2120
+ access_token: Optional[str] = None,
2121
+ name: Optional[str] = None,
2122
+ message: Optional[str] = None,
2123
+ messages: Optional[List[str]] = None,
2124
+ headline: Optional[str] = None,
2125
+ headlines: Optional[List[str]] = None,
2126
+ description: Optional[str] = None,
2127
+ descriptions: Optional[List[str]] = None,
2128
+ optimization_type: Optional[str] = None,
2129
+ dynamic_creative_spec: Optional[Dict[str, Any]] = None,
2130
+ call_to_action_type: Optional[str] = None,
2131
+ lead_gen_form_id: Optional[Union[str, int]] = None,
2132
+ ad_formats: Optional[List[str]] = None,
2133
+ creative_features_spec: Optional[Dict[str, Any]] = None
2134
+ ) -> str:
2135
+ """
2136
+ Update an existing ad creative's name or optimization settings.
2137
+
2138
+ IMPORTANT -- Meta API limitation: The Meta API does NOT allow updating content
2139
+ fields (message, headline, description, CTA, image, video, URL) on existing
2140
+ creatives. Only the creative `name` and optimization settings (asset_feed_spec)
2141
+ can be changed. To change ad content, create a new creative with the desired
2142
+ content and update the ad to reference the new creative via `update_ad`.
2143
+
2144
+ Args:
2145
+ creative_id: Meta Ads creative ID to update
2146
+ access_token: Meta API access token (optional - will use cached token if not provided)
2147
+ name: New creative name (this is the most reliable update)
2148
+ message: New ad copy/text -- NOTE: Meta API may reject this on existing creatives
2149
+ messages: List of primary text variants -- NOTE: Meta API may reject this on existing creatives
2150
+ headline: Single headline -- NOTE: Meta API may reject this on existing creatives
2151
+ headlines: New list of headlines -- NOTE: Meta API may reject this on existing creatives
2152
+ description: Single description -- NOTE: Meta API may reject this on existing creatives
2153
+ descriptions: New list of descriptions -- NOTE: Meta API may reject this on existing creatives
2154
+ optimization_type: Set to "DEGREES_OF_FREEDOM" for FLEX (Advantage+) creatives
2155
+ dynamic_creative_spec: New dynamic creative optimization settings
2156
+ call_to_action_type: New call to action button type -- NOTE: Meta API may reject this on existing creatives
2157
+ lead_gen_form_id: Lead generation form ID for lead generation campaigns
2158
+ ad_formats: List of ad format strings for asset_feed_spec (e.g., ["AUTOMATIC_FORMAT"] for
2159
+ Flexible ads, ["SINGLE_IMAGE"] for single image)
2160
+ creative_features_spec: Dict of Advantage+ Creative feature opt-ins/opt-outs.
2161
+ Each key is a feature name, value is {"enroll_status": "OPT_IN"|"OPT_OUT"}.
2162
+ Sent as a top-level field (not inside degrees_of_freedom_spec).
2163
+
2164
+ Returns:
2165
+ JSON response with updated creative details
2166
+ """
2167
+ # Coerce numeric IDs to strings (LLM clients may send integers for numeric-only IDs)
2168
+ if lead_gen_form_id is not None:
2169
+ lead_gen_form_id = str(lead_gen_form_id)
2170
+ # Check required parameters
2171
+ if not creative_id:
2172
+ return json.dumps({"error": "No creative ID provided"}, indent=2)
2173
+
2174
+ # Validate headline/description parameters - cannot mix simple and complex
2175
+ if headline and headlines:
2176
+ return json.dumps({"error": "Cannot specify both 'headline' and 'headlines'. Use 'headline' for single headline or 'headlines' for multiple."}, indent=2)
2177
+
2178
+ if description and descriptions:
2179
+ return json.dumps({"error": "Cannot specify both 'description' and 'descriptions'. Use 'description' for single description or 'descriptions' for multiple."}, indent=2)
2180
+
2181
+ # Validate message / messages mutual exclusivity
2182
+ if message and messages:
2183
+ return json.dumps({"error": "Cannot specify both 'message' and 'messages'. Use 'message' for single text or 'messages' for multiple variants."}, indent=2)
2184
+
2185
+ # Validate optimization_type
2186
+ if optimization_type and optimization_type != "DEGREES_OF_FREEDOM":
2187
+ return json.dumps({"error": f"Invalid optimization_type '{optimization_type}'. Only 'DEGREES_OF_FREEDOM' is supported."}, indent=2)
2188
+
2189
+ # Validate dynamic creative parameters (plural forms only)
2190
+ if headlines:
2191
+ if len(headlines) > 5:
2192
+ return json.dumps({"error": "Maximum 5 headlines allowed for dynamic creatives"}, indent=2)
2193
+ for i, h in enumerate(headlines):
2194
+ if len(h) > 40:
2195
+ return json.dumps({"error": f"Headline {i+1} exceeds 40 character limit"}, indent=2)
2196
+
2197
+ if descriptions:
2198
+ if len(descriptions) > 5:
2199
+ return json.dumps({"error": "Maximum 5 descriptions allowed for dynamic creatives"}, indent=2)
2200
+ for i, d in enumerate(descriptions):
2201
+ if len(d) > 125:
2202
+ return json.dumps({"error": f"Description {i+1} exceeds 125 character limit"}, indent=2)
2203
+
2204
+ # Prepare the update data
2205
+ update_data = {}
2206
+
2207
+ if name:
2208
+ update_data["name"] = name
2209
+
2210
+ # Choose between asset_feed_spec (dynamic/FLEX creative) or object_story_spec (traditional)
2211
+ use_asset_feed = bool(headlines or descriptions or messages or optimization_type or dynamic_creative_spec)
2212
+
2213
+ if use_asset_feed:
2214
+ # Handle dynamic/FLEX creative assets via asset_feed_spec
2215
+ asset_feed_spec = {}
2216
+
2217
+ # Determine ad_formats: use explicit value if provided, otherwise smart default.
2218
+ # NOTE: AUTOMATIC_FORMAT is NOT valid for creation/update -- Meta silently
2219
+ # ignores the entire asset_feed_spec when it encounters it.
2220
+ # Always use SINGLE_IMAGE; Meta handles format selection automatically
2221
+ # via optimization_type=DEGREES_OF_FREEDOM.
2222
+ if ad_formats:
2223
+ asset_feed_spec["ad_formats"] = ad_formats
2224
+ else:
2225
+ asset_feed_spec["ad_formats"] = ["SINGLE_IMAGE"]
2226
+
2227
+ # Add optimization_type for FLEX (Advantage+) creatives
2228
+ if optimization_type:
2229
+ asset_feed_spec["optimization_type"] = optimization_type
2230
+
2231
+ # Handle headlines - Meta API uses "titles" not "headlines" in asset_feed_spec
2232
+ # Auto-promote singular headline to single-element array when in asset_feed_spec path
2233
+ if headlines:
2234
+ asset_feed_spec["titles"] = [{"text": headline_text} for headline_text in headlines]
2235
+ elif headline:
2236
+ asset_feed_spec["titles"] = [{"text": headline}]
2237
+
2238
+ # Handle descriptions
2239
+ # Auto-promote singular description to single-element array when in asset_feed_spec path
2240
+ if descriptions:
2241
+ asset_feed_spec["descriptions"] = [{"text": description_text} for description_text in descriptions]
2242
+ elif description:
2243
+ asset_feed_spec["descriptions"] = [{"text": description}]
2244
+
2245
+ # Handle bodies: messages (plural) or message (singular)
2246
+ if messages:
2247
+ asset_feed_spec["bodies"] = [{"text": m} for m in messages]
2248
+ elif message:
2249
+ asset_feed_spec["bodies"] = [{"text": message}]
2250
+
2251
+ # Add call_to_action_types if provided
2252
+ if call_to_action_type:
2253
+ asset_feed_spec["call_to_action_types"] = [call_to_action_type]
2254
+
2255
+ update_data["asset_feed_spec"] = asset_feed_spec
2256
+ else:
2257
+ # Use traditional object_story_spec with link_data for simple creatives
2258
+ if message or headline or description or call_to_action_type or lead_gen_form_id:
2259
+ update_data["object_story_spec"] = {"link_data": {}}
2260
+
2261
+ if message:
2262
+ update_data["object_story_spec"]["link_data"]["message"] = message
2263
+
2264
+ # Add headline (singular) to link_data
2265
+ if headline:
2266
+ update_data["object_story_spec"]["link_data"]["name"] = headline
2267
+
2268
+ # Add description (singular) to link_data
2269
+ if description:
2270
+ update_data["object_story_spec"]["link_data"]["description"] = description
2271
+
2272
+ # Add call_to_action to link_data for simple creatives
2273
+ if call_to_action_type or lead_gen_form_id:
2274
+ cta_data = {}
2275
+ if call_to_action_type:
2276
+ cta_data["type"] = call_to_action_type
2277
+
2278
+ # Add lead form ID to value object if provided (required for lead generation campaigns)
2279
+ if lead_gen_form_id:
2280
+ cta_data["value"] = {"lead_gen_form_id": lead_gen_form_id}
2281
+
2282
+ if cta_data:
2283
+ update_data["object_story_spec"]["link_data"]["call_to_action"] = cta_data
2284
+
2285
+ # Add dynamic creative spec if provided
2286
+ if dynamic_creative_spec:
2287
+ update_data["dynamic_creative_spec"] = dynamic_creative_spec
2288
+
2289
+ # Add Advantage+ Creative feature opt-ins/opt-outs if provided.
2290
+ # Meta API docs: PUT /{ad_creative_id} accepts creative_features_spec
2291
+ # as a top-level field (NOT inside degrees_of_freedom_spec, which is immutable).
2292
+ if creative_features_spec:
2293
+ update_data["creative_features_spec"] = creative_features_spec
2294
+
2295
+ # Prepare the API endpoint for updating the creative
2296
+ endpoint = f"{creative_id}"
2297
+
2298
+ try:
2299
+ # Meta Graph API uses POST for all mutations (PUT returns "Object Not Found").
2300
+ # creative_features_spec is sent as a top-level POST field, NOT inside
2301
+ # degrees_of_freedom_spec (which is immutable after creation).
2302
+ data = await make_api_request(endpoint, access_token, update_data, method="POST")
2303
+
2304
+ # If successful, get more details about the updated creative
2305
+ if "id" in data:
2306
+ creative_endpoint = f"{creative_id}"
2307
+ creative_params = {
2308
+ "fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,url_tags,link_url,dynamic_creative_spec,degrees_of_freedom_spec"
2309
+ }
2310
+
2311
+ creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
2312
+ return json.dumps({
2313
+ "success": True,
2314
+ "creative_id": creative_id,
2315
+ "details": creative_details
2316
+ }, indent=2)
2317
+
2318
+ # Check for Meta API content update limitation (error_subcode 1815573)
2319
+ error_obj = data.get("error", {})
2320
+ if isinstance(error_obj, dict):
2321
+ error_details = error_obj.get("details", {})
2322
+ if isinstance(error_details, dict):
2323
+ inner_error = error_details.get("error", {})
2324
+ error_subcode = inner_error.get("error_subcode") if isinstance(inner_error, dict) else None
2325
+ else:
2326
+ error_subcode = error_obj.get("error_subcode")
2327
+ else:
2328
+ error_subcode = None
2329
+
2330
+ if error_subcode == 1815573:
2331
+ return json.dumps({
2332
+ "error": "Content updates are not allowed on existing creatives",
2333
+ "explanation": (
2334
+ "The Meta API does not allow updating content fields (message, headline, "
2335
+ "description, CTA, image, video, URL) on existing creatives. "
2336
+ "Only the creative 'name' can be changed."
2337
+ ),
2338
+ "workaround": (
2339
+ "To change ad content: (1) create a new creative with the desired content "
2340
+ "using create_ad_creative, then (2) call update_ad with the ad's ID and the "
2341
+ "new creative_id to swap it on the ad."
2342
+ ),
2343
+ "creative_id": creative_id,
2344
+ "attempted_updates": update_data
2345
+ }, indent=2)
2346
+
2347
+ return json.dumps(data, indent=2)
2348
+
2349
+ except Exception as e:
2350
+ return json.dumps({
2351
+ "error": "Failed to update ad creative",
2352
+ "details": str(e),
2353
+ "update_data_sent": update_data
2354
+ }, indent=2)
2355
+
2356
+
2357
+ async def _discover_pages_for_account(account_id: str, access_token: str) -> dict:
2358
+ """
2359
+ Internal function to discover pages for an account using multiple approaches.
2360
+ Returns the best available page ID for ad creation.
2361
+ """
2362
+ try:
2363
+ # Approach 1: Extract page IDs from tracking_specs in ads (most reliable)
2364
+ endpoint = f"{account_id}/ads"
2365
+ params = {
2366
+ "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
2367
+ "limit": 100
2368
+ }
2369
+
2370
+ tracking_ads_data = await make_api_request(endpoint, access_token, params)
2371
+
2372
+ tracking_page_ids = set()
2373
+ if "data" in tracking_ads_data:
2374
+ for ad in tracking_ads_data.get("data", []):
2375
+ tracking_specs = ad.get("tracking_specs", [])
2376
+ if isinstance(tracking_specs, list):
2377
+ for spec in tracking_specs:
2378
+ if isinstance(spec, dict) and "page" in spec:
2379
+ page_list = spec["page"]
2380
+ if isinstance(page_list, list):
2381
+ for page_id in page_list:
2382
+ if isinstance(page_id, (str, int)) and str(page_id).isdigit():
2383
+ tracking_page_ids.add(str(page_id))
2384
+
2385
+ if tracking_page_ids:
2386
+ # Get details for the first page found
2387
+ page_id = list(tracking_page_ids)[0]
2388
+ page_endpoint = f"{page_id}"
2389
+ page_params = {
2390
+ "fields": "id,name,username,category,fan_count,link,verification_status,picture"
2391
+ }
2392
+
2393
+ page_data = await make_api_request(page_endpoint, access_token, page_params)
2394
+ if "id" in page_data:
2395
+ return {
2396
+ "success": True,
2397
+ "page_id": page_id,
2398
+ "page_name": page_data.get("name", "Unknown"),
2399
+ "source": "tracking_specs",
2400
+ "note": "Page ID extracted from existing ads - most reliable for ad creation"
2401
+ }
2402
+
2403
+ # Approach 2: Try client_pages endpoint
2404
+ endpoint = f"{account_id}/client_pages"
2405
+ params = {
2406
+ "fields": "id,name,username,category,fan_count,link,verification_status,picture"
2407
+ }
2408
+
2409
+ client_pages_data = await make_api_request(endpoint, access_token, params)
2410
+
2411
+ if "data" in client_pages_data and client_pages_data["data"]:
2412
+ page = client_pages_data["data"][0]
2413
+ return {
2414
+ "success": True,
2415
+ "page_id": str(page["id"]),
2416
+ "page_name": page.get("name", "Unknown"),
2417
+ "source": "client_pages"
2418
+ }
2419
+
2420
+ # Approach 3: Try assigned_pages endpoint
2421
+ pages_endpoint = f"{account_id}/assigned_pages"
2422
+ pages_params = {
2423
+ "fields": "id,name",
2424
+ "limit": 1
2425
+ }
2426
+
2427
+ pages_data = await make_api_request(pages_endpoint, access_token, pages_params)
2428
+
2429
+ if "data" in pages_data and pages_data["data"]:
2430
+ page = pages_data["data"][0]
2431
+ return {
2432
+ "success": True,
2433
+ "page_id": str(page["id"]),
2434
+ "page_name": page.get("name", "Unknown"),
2435
+ "source": "assigned_pages"
2436
+ }
2437
+
2438
+ # If all approaches failed
2439
+ return {
2440
+ "success": False,
2441
+ "message": "No suitable pages found for this account",
2442
+ "note": "Try using get_account_pages to see all available pages or provide page_id manually"
2443
+ }
2444
+
2445
+ except Exception as e:
2446
+ return {
2447
+ "success": False,
2448
+ "message": f"Error during page discovery: {str(e)}"
2449
+ }
2450
+
2451
+
2452
+ async def _search_pages_by_name_core(access_token: str, account_id: str, search_term: str = None) -> str:
2453
+ """
2454
+ Core logic for searching pages by name.
2455
+
2456
+ Args:
2457
+ access_token: Meta API access token
2458
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX)
2459
+ search_term: Search term to find pages by name (optional - returns all pages if not provided)
2460
+
2461
+ Returns:
2462
+ JSON string with search results
2463
+ """
2464
+ account_id = ensure_act_prefix(account_id)
2465
+
2466
+ try:
2467
+ # Use the internal discovery function directly
2468
+ page_discovery_result = await _discover_pages_for_account(account_id, access_token)
2469
+
2470
+ if not page_discovery_result.get("success"):
2471
+ return json.dumps({
2472
+ "data": [],
2473
+ "message": "No pages found for this account",
2474
+ "details": page_discovery_result.get("message", "Page discovery failed")
2475
+ }, indent=2)
2476
+
2477
+ # Create a single page result
2478
+ page_data = {
2479
+ "id": page_discovery_result["page_id"],
2480
+ "name": page_discovery_result.get("page_name", "Unknown"),
2481
+ "source": page_discovery_result.get("source", "unknown")
2482
+ }
2483
+
2484
+ all_pages_data = {"data": [page_data]}
2485
+
2486
+ # Filter pages by search term if provided
2487
+ if search_term:
2488
+ search_term_lower = search_term.lower()
2489
+ filtered_pages = []
2490
+
2491
+ for page in all_pages_data["data"]:
2492
+ page_name = page.get("name", "").lower()
2493
+ if search_term_lower in page_name:
2494
+ filtered_pages.append(page)
2495
+
2496
+ return json.dumps({
2497
+ "data": filtered_pages,
2498
+ "search_term": search_term,
2499
+ "total_found": len(filtered_pages),
2500
+ "total_available": len(all_pages_data["data"])
2501
+ }, indent=2)
2502
+ else:
2503
+ # Return all pages if no search term provided
2504
+ return json.dumps({
2505
+ "data": all_pages_data["data"],
2506
+ "total_available": len(all_pages_data["data"]),
2507
+ "note": "Use search_term parameter to filter pages by name"
2508
+ }, indent=2)
2509
+
2510
+ except Exception as e:
2511
+ return json.dumps({
2512
+ "error": "Failed to search pages by name",
2513
+ "details": str(e)
2514
+ }, indent=2)
2515
+
2516
+
2517
+ @mcp_server.tool()
2518
+ @meta_api_tool
2519
+ async def search_pages_by_name(account_id: str, access_token: Optional[str] = None, search_term: Optional[str] = None) -> str:
2520
+ """
2521
+ Search for pages by name within an account.
2522
+
2523
+ Args:
2524
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX)
2525
+ access_token: Meta API access token (optional - will use cached token if not provided)
2526
+ search_term: Search term to find pages by name (optional - returns all pages if not provided)
2527
+
2528
+ Returns:
2529
+ JSON response with matching pages
2530
+ """
2531
+ # Check required parameters
2532
+ if not account_id:
2533
+ return json.dumps({"error": "No account ID provided"}, indent=2)
2534
+
2535
+ # Call the core function
2536
+ result = await _search_pages_by_name_core(access_token, account_id, search_term)
2537
+ return result
2538
+
2539
+
2540
+ @mcp_server.tool()
2541
+ @meta_api_tool
2542
+ async def get_account_pages(account_id: str, access_token: Optional[str] = None) -> str:
2543
+ """
2544
+ Get pages associated with a Meta Ads account.
2545
+
2546
+ Args:
2547
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX)
2548
+ access_token: Meta API access token (optional - will use cached token if not provided)
2549
+
2550
+ Returns:
2551
+ JSON response with pages associated with the account
2552
+ """
2553
+ # Check required parameters
2554
+ if not account_id:
2555
+ return json.dumps({"error": "No account ID provided"}, indent=2)
2556
+
2557
+ # Handle special case for 'me'
2558
+ if account_id == "me":
2559
+ try:
2560
+ endpoint = "me/accounts"
2561
+ params = {
2562
+ "fields": "id,name,username,category,fan_count,link,verification_status,picture"
2563
+ }
2564
+
2565
+ user_pages_data = await make_api_request(endpoint, access_token, params)
2566
+ return json.dumps(user_pages_data, indent=2)
2567
+ except Exception as e:
2568
+ return json.dumps({
2569
+ "error": "Failed to get user pages",
2570
+ "details": str(e)
2571
+ }, indent=2)
2572
+
2573
+ account_id = ensure_act_prefix(account_id)
2574
+
2575
+ try:
2576
+ # Collect all page IDs from multiple approaches
2577
+ all_page_ids = set()
2578
+
2579
+ # Approach 1: Get user's personal pages (broad scope)
2580
+ try:
2581
+ endpoint = "me/accounts"
2582
+ params = {
2583
+ "fields": "id,name,username,category,fan_count,link,verification_status,picture"
2584
+ }
2585
+ user_pages_data = await make_api_request(endpoint, access_token, params)
2586
+ if "data" in user_pages_data:
2587
+ for page in user_pages_data["data"]:
2588
+ if "id" in page:
2589
+ all_page_ids.add(page["id"])
2590
+ except Exception:
2591
+ pass
2592
+
2593
+ # Approach 2: Try business manager pages
2594
+ try:
2595
+ # Strip 'act_' prefix to get raw account ID for business endpoints
2596
+ raw_account_id = account_id.replace("act_", "")
2597
+ endpoint = f"{raw_account_id}/owned_pages"
2598
+ params = {
2599
+ "fields": "id,name,username,category,fan_count,link,verification_status,picture"
2600
+ }
2601
+ business_pages_data = await make_api_request(endpoint, access_token, params)
2602
+ if "data" in business_pages_data:
2603
+ for page in business_pages_data["data"]:
2604
+ if "id" in page:
2605
+ all_page_ids.add(page["id"])
2606
+ except Exception:
2607
+ pass
2608
+
2609
+ # Approach 3: Try ad account client pages
2610
+ try:
2611
+ endpoint = f"{account_id}/client_pages"
2612
+ params = {
2613
+ "fields": "id,name,username,category,fan_count,link,verification_status,picture"
2614
+ }
2615
+ client_pages_data = await make_api_request(endpoint, access_token, params)
2616
+ if "data" in client_pages_data:
2617
+ for page in client_pages_data["data"]:
2618
+ if "id" in page:
2619
+ all_page_ids.add(page["id"])
2620
+ except Exception:
2621
+ pass
2622
+
2623
+ # Approach 4: Extract page IDs from all ad creatives (broader creative search)
2624
+ try:
2625
+ endpoint = f"{account_id}/adcreatives"
2626
+ params = {
2627
+ "fields": "id,name,object_story_spec,link_url,call_to_action,image_hash",
2628
+ "limit": 100
2629
+ }
2630
+ creatives_data = await make_api_request(endpoint, access_token, params)
2631
+ if "data" in creatives_data:
2632
+ for creative in creatives_data["data"]:
2633
+ if "object_story_spec" in creative and "page_id" in creative["object_story_spec"]:
2634
+ all_page_ids.add(creative["object_story_spec"]["page_id"])
2635
+ except Exception:
2636
+ pass
2637
+
2638
+ # Approach 5: Get active ads and extract page IDs from creatives
2639
+ try:
2640
+ endpoint = f"{account_id}/ads"
2641
+ params = {
2642
+ "fields": "creative{object_story_spec{page_id},link_url,call_to_action}",
2643
+ "limit": 100
2644
+ }
2645
+ ads_data = await make_api_request(endpoint, access_token, params)
2646
+ if "data" in ads_data:
2647
+ for ad in ads_data.get("data", []):
2648
+ if "creative" in ad and "object_story_spec" in ad["creative"] and "page_id" in ad["creative"]["object_story_spec"]:
2649
+ all_page_ids.add(ad["creative"]["object_story_spec"]["page_id"])
2650
+ except Exception:
2651
+ pass
2652
+
2653
+ # Approach 6: Try promoted_objects endpoint
2654
+ try:
2655
+ endpoint = f"{account_id}/promoted_objects"
2656
+ params = {
2657
+ "fields": "page_id,object_store_url,product_set_id,application_id"
2658
+ }
2659
+ promoted_objects_data = await make_api_request(endpoint, access_token, params)
2660
+ if "data" in promoted_objects_data:
2661
+ for obj in promoted_objects_data["data"]:
2662
+ if "page_id" in obj:
2663
+ all_page_ids.add(obj["page_id"])
2664
+ except Exception:
2665
+ pass
2666
+
2667
+ # Approach 7: Extract page IDs from tracking_specs in ads (most reliable)
2668
+ try:
2669
+ endpoint = f"{account_id}/ads"
2670
+ params = {
2671
+ "fields": "id,name,status,creative,tracking_specs",
2672
+ "limit": 100
2673
+ }
2674
+ tracking_ads_data = await make_api_request(endpoint, access_token, params)
2675
+ if "data" in tracking_ads_data:
2676
+ for ad in tracking_ads_data.get("data", []):
2677
+ tracking_specs = ad.get("tracking_specs", [])
2678
+ if isinstance(tracking_specs, list):
2679
+ for spec in tracking_specs:
2680
+ if isinstance(spec, dict) and "page" in spec:
2681
+ page_list = spec["page"]
2682
+ if isinstance(page_list, list):
2683
+ for page_id in page_list:
2684
+ if isinstance(page_id, (str, int)) and str(page_id).isdigit():
2685
+ all_page_ids.add(str(page_id))
2686
+ except Exception:
2687
+ pass
2688
+
2689
+ # Approach 8: Try campaigns and extract page info
2690
+ try:
2691
+ endpoint = f"{account_id}/campaigns"
2692
+ params = {
2693
+ "fields": "id,name,promoted_object,objective",
2694
+ "limit": 50
2695
+ }
2696
+ campaigns_data = await make_api_request(endpoint, access_token, params)
2697
+ if "data" in campaigns_data:
2698
+ for campaign in campaigns_data["data"]:
2699
+ if "promoted_object" in campaign and "page_id" in campaign["promoted_object"]:
2700
+ all_page_ids.add(campaign["promoted_object"]["page_id"])
2701
+ except Exception:
2702
+ pass
2703
+
2704
+ # If we found any page IDs, get details for each
2705
+ if all_page_ids:
2706
+ page_details = {
2707
+ "data": [],
2708
+ "total_pages_found": len(all_page_ids)
2709
+ }
2710
+
2711
+ for page_id in all_page_ids:
2712
+ try:
2713
+ page_endpoint = f"{page_id}"
2714
+ page_params = {
2715
+ "fields": "id,name,username,category,fan_count,link,verification_status,picture"
2716
+ }
2717
+
2718
+ page_data = await make_api_request(page_endpoint, access_token, page_params)
2719
+ if "id" in page_data:
2720
+ page_details["data"].append(page_data)
2721
+ else:
2722
+ page_details["data"].append({
2723
+ "id": page_id,
2724
+ "error": "Page details not accessible"
2725
+ })
2726
+ except Exception as e:
2727
+ page_details["data"].append({
2728
+ "id": page_id,
2729
+ "error": f"Failed to get page details: {str(e)}"
2730
+ })
2731
+
2732
+ if page_details["data"]:
2733
+ return json.dumps(page_details, indent=2)
2734
+
2735
+ # If all approaches failed, return empty data with a message
2736
+ return json.dumps({
2737
+ "data": [],
2738
+ "message": "No pages found associated with this account",
2739
+ "suggestion": "Create a Facebook page and connect it to this ad account, or ensure existing pages are properly connected through Business Manager"
2740
+ }, indent=2)
2741
+
2742
+ except Exception as e:
2743
+ return json.dumps({
2744
+ "error": "Failed to get account pages",
2745
+ "details": str(e)
2746
+ }, indent=2)
2747
+
2748
+
2749
+
2750
+
2751
+