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,542 @@
1
+ """Targeting search functionality for Meta Ads API."""
2
+
3
+ import json
4
+ from typing import Optional, List, Dict, Any, Union
5
+ import os
6
+ from .api import meta_api_tool, make_api_request
7
+ from .server import mcp_server
8
+
9
+
10
+ @mcp_server.tool()
11
+ @meta_api_tool
12
+ async def search_interests(query: str, access_token: Optional[str] = None, limit: int = 25) -> str:
13
+ """
14
+ Search for interest targeting options by keyword.
15
+
16
+ Args:
17
+ query: Search term for interests (e.g., "baseball", "cooking", "travel")
18
+ access_token: Meta API access token (optional - will use cached token if not provided)
19
+ limit: Maximum number of results to return (default: 25)
20
+
21
+ Returns:
22
+ JSON string containing interest data with id, name, audience_size, and path fields
23
+ """
24
+ if not query:
25
+ return json.dumps({"error": "No search query provided"}, indent=2)
26
+
27
+ endpoint = "search"
28
+ params = {
29
+ "type": "adinterest",
30
+ "q": query,
31
+ "limit": limit
32
+ }
33
+
34
+ data = await make_api_request(endpoint, access_token, params)
35
+
36
+ return json.dumps(data, indent=2)
37
+
38
+
39
+ @mcp_server.tool()
40
+ @meta_api_tool
41
+ async def get_interest_suggestions(interest_list: List[str], access_token: Optional[str] = None, limit: int = 25) -> str:
42
+ """
43
+ Get interest suggestions based on existing interests.
44
+
45
+ Args:
46
+ interest_list: List of interest names to get suggestions for (e.g., ["Basketball", "Soccer"])
47
+ access_token: Meta API access token (optional - will use cached token if not provided)
48
+ limit: Maximum number of suggestions to return (default: 25)
49
+
50
+ Returns:
51
+ JSON string containing suggested interests with id, name, audience_size, and description fields
52
+ """
53
+ if not interest_list:
54
+ return json.dumps({"error": "No interest list provided"}, indent=2)
55
+
56
+ endpoint = "search"
57
+ params = {
58
+ "type": "adinterestsuggestion",
59
+ "interest_list": json.dumps(interest_list),
60
+ "limit": limit
61
+ }
62
+
63
+ data = await make_api_request(endpoint, access_token, params)
64
+
65
+ return json.dumps(data, indent=2)
66
+
67
+
68
+ @mcp_server.tool()
69
+ @meta_api_tool
70
+ async def estimate_audience_size(
71
+ access_token: Optional[str] = None,
72
+ account_id: Optional[Union[str, int]] = None,
73
+ targeting: Optional[Dict[str, Any]] = None,
74
+ optimization_goal: str = "REACH",
75
+ # Backwards compatibility for simple interest validation
76
+ interest_list: Optional[List[str]] = None,
77
+ interest_fbid_list: Optional[List[str]] = None
78
+ ) -> str:
79
+ """
80
+ Estimate audience size for targeting specifications using Meta's delivery_estimate API.
81
+
82
+ This function provides comprehensive audience estimation for complex targeting combinations
83
+ including demographics, geography, interests, and behaviors. It also maintains backwards
84
+ compatibility for simple interest validation.
85
+
86
+ Args:
87
+ access_token: Meta API access token (optional - will use cached token if not provided)
88
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX) - required for comprehensive estimation
89
+ targeting: Complete targeting specification including demographics, geography, interests, etc.
90
+ Example: {
91
+ "age_min": 25,
92
+ "age_max": 65,
93
+ "geo_locations": {"countries": ["PL"]},
94
+ "flexible_spec": [
95
+ {"interests": [{"id": "6003371567474"}]},
96
+ {"interests": [{"id": "6003462346642"}]}
97
+ ]
98
+ }
99
+ optimization_goal: Optimization goal for estimation (default: "REACH").
100
+ Options: "REACH", "LINK_CLICKS", "IMPRESSIONS", "CONVERSIONS", etc.
101
+ interest_list: [DEPRECATED - for backwards compatibility] List of interest names to validate
102
+ interest_fbid_list: [DEPRECATED - for backwards compatibility] List of interest IDs to validate
103
+
104
+ Returns:
105
+ JSON string with audience estimation results including estimated_audience_size,
106
+ reach_estimate, and targeting validation
107
+ """
108
+ # Coerce numeric IDs to strings
109
+ if account_id is not None:
110
+ account_id = str(account_id)
111
+
112
+ # Handle backwards compatibility - simple interest validation
113
+ # Check if we're in backwards compatibility mode (interest params provided OR no comprehensive params)
114
+ is_backwards_compatible_call = (interest_list or interest_fbid_list) or (not account_id and not targeting)
115
+
116
+ if is_backwards_compatible_call and not targeting:
117
+ if not interest_list and not interest_fbid_list:
118
+ return json.dumps({"error": "No interest list or FBID list provided"}, indent=2)
119
+
120
+ endpoint = "search"
121
+ params = {
122
+ "type": "adinterestvalid"
123
+ }
124
+
125
+ if interest_list:
126
+ params["interest_list"] = json.dumps(interest_list)
127
+
128
+ if interest_fbid_list:
129
+ params["interest_fbid_list"] = json.dumps(interest_fbid_list)
130
+
131
+ data = await make_api_request(endpoint, access_token, params)
132
+
133
+ return json.dumps(data, indent=2)
134
+
135
+ # Comprehensive audience estimation using delivery_estimate API
136
+ if not account_id:
137
+ return json.dumps({
138
+ "error": "account_id is required for comprehensive audience estimation",
139
+ "details": "For simple interest validation, use interest_list or interest_fbid_list parameters"
140
+ }, indent=2)
141
+
142
+ if not targeting:
143
+ return json.dumps({
144
+ "error": "targeting specification is required for comprehensive audience estimation",
145
+ "example": {
146
+ "age_min": 25,
147
+ "age_max": 65,
148
+ "geo_locations": {"countries": ["US"]},
149
+ "flexible_spec": [
150
+ {"interests": [{"id": "6003371567474"}]}
151
+ ]
152
+ }
153
+ }, indent=2)
154
+
155
+ # Preflight validation: require at least one location OR a custom audience
156
+ def _has_location_or_custom_audience(t: Dict[str, Any]) -> bool:
157
+ if not isinstance(t, dict):
158
+ return False
159
+ geo = t.get("geo_locations") or {}
160
+ if isinstance(geo, dict):
161
+ for key in [
162
+ "countries",
163
+ "regions",
164
+ "cities",
165
+ "zips",
166
+ "geo_markets",
167
+ "country_groups"
168
+ ]:
169
+ val = geo.get(key)
170
+ if isinstance(val, list) and len(val) > 0:
171
+ return True
172
+ # Top-level custom audiences
173
+ ca = t.get("custom_audiences")
174
+ if isinstance(ca, list) and len(ca) > 0:
175
+ return True
176
+ # Custom audiences within flexible_spec
177
+ flex = t.get("flexible_spec")
178
+ if isinstance(flex, list):
179
+ for spec in flex:
180
+ if isinstance(spec, dict):
181
+ ca_spec = spec.get("custom_audiences")
182
+ if isinstance(ca_spec, list) and len(ca_spec) > 0:
183
+ return True
184
+ return False
185
+
186
+ if not _has_location_or_custom_audience(targeting):
187
+ return json.dumps({
188
+ "error": "Missing target audience location",
189
+ "details": "Select at least one location in targeting.geo_locations or include a custom audience.",
190
+ "action_required": "Add geo_locations with countries/regions/cities/zips or include custom_audiences.",
191
+ "example": {
192
+ "geo_locations": {"countries": ["US"]},
193
+ "age_min": 25,
194
+ "age_max": 65
195
+ }
196
+ }, indent=2)
197
+
198
+ # Build reach estimate request (using correct Meta API endpoint)
199
+ endpoint = f"{account_id}/reachestimate"
200
+ params = {
201
+ "targeting_spec": targeting
202
+ }
203
+
204
+ # Note: reachestimate endpoint doesn't support optimization_goal or objective parameters
205
+
206
+ try:
207
+ data = await make_api_request(endpoint, access_token, params, method="GET")
208
+
209
+ # Surface Graph API errors directly for better diagnostics.
210
+ # If reachestimate fails, optionally attempt a fallback using delivery_estimate.
211
+ if isinstance(data, dict) and "error" in data:
212
+ # Special handling for Missing Target Audience Location error (subcode 1885364)
213
+ try:
214
+ err_wrapper = data.get("error", {})
215
+ details_obj = err_wrapper.get("details", {})
216
+ raw_err = details_obj.get("error", {}) if isinstance(details_obj, dict) else {}
217
+ if (
218
+ isinstance(raw_err, dict) and (
219
+ raw_err.get("error_subcode") == 1885364 or
220
+ raw_err.get("error_user_title") == "Missing Target Audience Location"
221
+ )
222
+ ):
223
+ return json.dumps({
224
+ "error": "Missing target audience location",
225
+ "details": raw_err.get("error_user_msg") or "Select at least one location, or choose a custom audience.",
226
+ "endpoint_used": f"{account_id}/reachestimate",
227
+ "action_required": "Add geo_locations with at least one of countries/regions/cities/zips or include custom_audiences.",
228
+ "blame_field_specs": raw_err.get("error_data", {}).get("blame_field_specs") if isinstance(raw_err.get("error_data"), dict) else None
229
+ }, indent=2)
230
+ except Exception:
231
+ pass
232
+ # Allow disabling fallback via environment variable
233
+ # Default: fallback disabled unless explicitly enabled by setting DISABLE flag to "0"
234
+ disable_fallback = os.environ.get("META_MCP_DISABLE_DELIVERY_FALLBACK", "1") == "1"
235
+ if disable_fallback:
236
+ return json.dumps({
237
+ "error": "Graph API returned an error for reachestimate",
238
+ "details": data.get("error"),
239
+ "endpoint_used": f"{account_id}/reachestimate",
240
+ "request_params": {
241
+ "has_targeting_spec": bool(targeting),
242
+ },
243
+ "note": "delivery_estimate fallback disabled via META_MCP_DISABLE_DELIVERY_FALLBACK"
244
+ }, indent=2)
245
+
246
+ # Try fallback to delivery_estimate endpoint
247
+ try:
248
+ fallback_endpoint = f"{account_id}/delivery_estimate"
249
+ fallback_params = {
250
+ "targeting_spec": json.dumps(targeting),
251
+ # Some API versions accept optimization_goal here
252
+ "optimization_goal": optimization_goal
253
+ }
254
+ fallback_data = await make_api_request(fallback_endpoint, access_token, fallback_params, method="GET")
255
+
256
+ # If fallback returns usable data, format similarly
257
+ if isinstance(fallback_data, dict) and "data" in fallback_data and len(fallback_data["data"]) > 0:
258
+ estimate_data = fallback_data["data"][0]
259
+ formatted_response = {
260
+ "success": True,
261
+ "account_id": account_id,
262
+ "targeting": targeting,
263
+ "optimization_goal": optimization_goal,
264
+ "estimated_audience_size": estimate_data.get("estimate_mau", 0),
265
+ "estimate_details": {
266
+ "monthly_active_users": estimate_data.get("estimate_mau", 0),
267
+ "daily_outcomes_curve": estimate_data.get("estimate_dau", []),
268
+ "bid_estimate": estimate_data.get("bid_estimates", {}),
269
+ "unsupported_targeting": estimate_data.get("unsupported_targeting", [])
270
+ },
271
+ "raw_response": fallback_data,
272
+ "fallback_endpoint_used": "delivery_estimate"
273
+ }
274
+ return json.dumps(formatted_response, indent=2)
275
+
276
+ # Fallback returned but not in expected format
277
+ return json.dumps({
278
+ "error": "Graph API returned an error for reachestimate; delivery_estimate fallback did not return usable data",
279
+ "reachestimate_error": data.get("error"),
280
+ "fallback_endpoint_used": "delivery_estimate",
281
+ "fallback_raw_response": fallback_data,
282
+ "endpoint_used": f"{account_id}/reachestimate",
283
+ "request_params": {
284
+ "has_targeting_spec": bool(targeting)
285
+ }
286
+ }, indent=2)
287
+ except Exception as _fallback_exc:
288
+ return json.dumps({
289
+ "error": "Graph API returned an error for reachestimate; delivery_estimate fallback also failed",
290
+ "reachestimate_error": data.get("error"),
291
+ "fallback_endpoint_used": "delivery_estimate",
292
+ "fallback_exception": str(_fallback_exc),
293
+ "endpoint_used": f"{account_id}/reachestimate",
294
+ "request_params": {
295
+ "has_targeting_spec": bool(targeting)
296
+ }
297
+ }, indent=2)
298
+
299
+ # Format the response for easier consumption
300
+ if "data" in data:
301
+ response_data = data["data"]
302
+ # Case 1: delivery_estimate-like list structure
303
+ if isinstance(response_data, list) and len(response_data) > 0:
304
+ estimate_data = response_data[0]
305
+ formatted_response = {
306
+ "success": True,
307
+ "account_id": account_id,
308
+ "targeting": targeting,
309
+ "optimization_goal": optimization_goal,
310
+ "estimated_audience_size": estimate_data.get("estimate_mau", 0),
311
+ "estimate_details": {
312
+ "monthly_active_users": estimate_data.get("estimate_mau", 0),
313
+ "daily_outcomes_curve": estimate_data.get("estimate_dau", []),
314
+ "bid_estimate": estimate_data.get("bid_estimates", {}),
315
+ "unsupported_targeting": estimate_data.get("unsupported_targeting", [])
316
+ },
317
+ "raw_response": data
318
+ }
319
+ return json.dumps(formatted_response, indent=2)
320
+ # Case 1b: explicit handling for empty list responses
321
+ if isinstance(response_data, list) and len(response_data) == 0:
322
+ return json.dumps({
323
+ "error": "No estimation data returned from Meta API",
324
+ "raw_response": data,
325
+ "debug_info": {
326
+ "response_keys": list(data.keys()) if isinstance(data, dict) else "not_a_dict",
327
+ "response_type": str(type(data)),
328
+ "endpoint_used": f"{account_id}/reachestimate"
329
+ }
330
+ }, indent=2)
331
+ # Case 2: reachestimate dict structure with bounds
332
+ if isinstance(response_data, dict):
333
+ lower = response_data.get("users_lower_bound", response_data.get("estimate_mau_lower_bound"))
334
+ upper = response_data.get("users_upper_bound", response_data.get("estimate_mau_upper_bound"))
335
+ estimate_ready = response_data.get("estimate_ready")
336
+ midpoint = None
337
+ try:
338
+ if isinstance(lower, (int, float)) and isinstance(upper, (int, float)):
339
+ midpoint = int((lower + upper) / 2)
340
+ except Exception:
341
+ midpoint = None
342
+ formatted_response = {
343
+ "success": True,
344
+ "account_id": account_id,
345
+ "targeting": targeting,
346
+ "optimization_goal": optimization_goal,
347
+ "estimated_audience_size": midpoint if midpoint is not None else 0,
348
+ "estimate_details": {
349
+ "users_lower_bound": lower,
350
+ "users_upper_bound": upper,
351
+ "estimate_ready": estimate_ready
352
+ },
353
+ "raw_response": data
354
+ }
355
+ return json.dumps(formatted_response, indent=2)
356
+ else:
357
+ return json.dumps({
358
+ "error": "No estimation data returned from Meta API",
359
+ "raw_response": data,
360
+ "debug_info": {
361
+ "response_keys": list(data.keys()) if isinstance(data, dict) else "not_a_dict",
362
+ "response_type": str(type(data)),
363
+ "endpoint_used": f"{account_id}/reachestimate"
364
+ }
365
+ }, indent=2)
366
+
367
+ except Exception as e:
368
+ # Try fallback to delivery_estimate first when an exception occurs (unless disabled)
369
+ # Default: fallback disabled unless explicitly enabled by setting DISABLE flag to "0"
370
+ disable_fallback = os.environ.get("META_MCP_DISABLE_DELIVERY_FALLBACK", "1") == "1"
371
+ if not disable_fallback:
372
+ try:
373
+ fallback_endpoint = f"{account_id}/delivery_estimate"
374
+ fallback_params = {
375
+ "targeting_spec": json.dumps(targeting) if isinstance(targeting, dict) else targeting,
376
+ "optimization_goal": optimization_goal
377
+ }
378
+ fallback_data = await make_api_request(fallback_endpoint, access_token, fallback_params, method="GET")
379
+
380
+ if isinstance(fallback_data, dict) and "data" in fallback_data and len(fallback_data["data"]) > 0:
381
+ estimate_data = fallback_data["data"][0]
382
+ formatted_response = {
383
+ "success": True,
384
+ "account_id": account_id,
385
+ "targeting": targeting,
386
+ "optimization_goal": optimization_goal,
387
+ "estimated_audience_size": estimate_data.get("estimate_mau", 0),
388
+ "estimate_details": {
389
+ "monthly_active_users": estimate_data.get("estimate_mau", 0),
390
+ "daily_outcomes_curve": estimate_data.get("estimate_dau", []),
391
+ "bid_estimate": estimate_data.get("bid_estimates", {}),
392
+ "unsupported_targeting": estimate_data.get("unsupported_targeting", [])
393
+ },
394
+ "raw_response": fallback_data,
395
+ "fallback_endpoint_used": "delivery_estimate"
396
+ }
397
+ return json.dumps(formatted_response, indent=2)
398
+ except Exception as _fallback_exc:
399
+ # If fallback also fails, proceed to detailed error handling below
400
+ pass
401
+
402
+ # Check if this is the specific Business Manager system user permission error
403
+ error_str = str(e)
404
+ if "100" in error_str and "33" in error_str:
405
+ # Try to provide fallback estimation using individual interests if available
406
+ interests_found = []
407
+ if targeting and "interests" in targeting:
408
+ interests_found.extend([interest.get("id") for interest in targeting["interests"] if interest.get("id")])
409
+ elif targeting and "flexible_spec" in targeting:
410
+ for spec in targeting["flexible_spec"]:
411
+ if "interests" in spec:
412
+ interests_found.extend([interest.get("id") for interest in spec["interests"] if interest.get("id")])
413
+
414
+ if interests_found:
415
+ # Attempt to get individual interest data as fallback
416
+ try:
417
+ fallback_result = await estimate_audience_size(
418
+ access_token=access_token,
419
+ interest_fbid_list=interests_found
420
+ )
421
+ fallback_data = json.loads(fallback_result)
422
+
423
+ return json.dumps({
424
+ "comprehensive_targeting_failed": True,
425
+ "error_code": "100-33",
426
+ "fallback_used": True,
427
+ "details": {
428
+ "issue": "reachestimate endpoint returned error - possibly due to targeting parameters or account limitations",
429
+ "solution": "Individual interest validation used as fallback - comprehensive targeting may have specific requirements",
430
+ "endpoint_used": f"{account_id}/reachestimate"
431
+ },
432
+ "individual_interest_data": fallback_data,
433
+ "note": "Individual interest audience sizes provided as fallback. Comprehensive targeting via reachestimate endpoint failed."
434
+ }, indent=2)
435
+ except:
436
+ pass
437
+
438
+ return json.dumps({
439
+ "error": "reachestimate endpoint returned error (previously was incorrectly using delivery_estimate)",
440
+ "error_code": "100-33",
441
+ "details": {
442
+ "issue": "The endpoint returned an error, possibly due to targeting parameters or account limitations",
443
+ "endpoint_used": f"{account_id}/reachestimate",
444
+ "previous_issue": "Code was previously using non-existent delivery_estimate endpoint - now fixed",
445
+ "available_alternative": "Use interest_list or interest_fbid_list parameters for individual interest validation"
446
+ },
447
+ "raw_error": error_str
448
+ }, indent=2)
449
+ else:
450
+ return json.dumps({
451
+ "error": f"Failed to get audience estimation from reachestimate endpoint: {str(e)}",
452
+ "details": "Check targeting parameters and account permissions",
453
+ "error_type": "general_api_error",
454
+ "endpoint_used": f"{account_id}/reachestimate"
455
+ }, indent=2)
456
+
457
+
458
+ @mcp_server.tool()
459
+ @meta_api_tool
460
+ async def search_behaviors(access_token: Optional[str] = None, limit: int = 50) -> str:
461
+ """
462
+ Get all available behavior targeting options.
463
+
464
+ Args:
465
+ access_token: Meta API access token (optional - will use cached token if not provided)
466
+ limit: Maximum number of results to return (default: 50)
467
+
468
+ Returns:
469
+ JSON string containing behavior targeting options with id, name, audience_size bounds, path, and description
470
+ """
471
+ endpoint = "search"
472
+ params = {
473
+ "type": "adTargetingCategory",
474
+ "class": "behaviors",
475
+ "limit": limit
476
+ }
477
+
478
+ data = await make_api_request(endpoint, access_token, params)
479
+
480
+ return json.dumps(data, indent=2)
481
+
482
+
483
+ @mcp_server.tool()
484
+ @meta_api_tool
485
+ async def search_demographics(access_token: Optional[str] = None, demographic_class: str = "demographics", limit: int = 50) -> str:
486
+ """
487
+ Get demographic targeting options.
488
+
489
+ Args:
490
+ access_token: Meta API access token (optional - will use cached token if not provided)
491
+ demographic_class: Type of demographics to retrieve. Options: 'demographics', 'life_events',
492
+ 'industries', 'income', 'family_statuses', 'user_device', 'user_os' (default: 'demographics')
493
+ limit: Maximum number of results to return (default: 50)
494
+
495
+ Returns:
496
+ JSON string containing demographic targeting options with id, name, audience_size bounds, path, and description
497
+ """
498
+ endpoint = "search"
499
+ params = {
500
+ "type": "adTargetingCategory",
501
+ "class": demographic_class,
502
+ "limit": limit
503
+ }
504
+
505
+ data = await make_api_request(endpoint, access_token, params)
506
+
507
+ return json.dumps(data, indent=2)
508
+
509
+
510
+ @mcp_server.tool()
511
+ @meta_api_tool
512
+ async def search_geo_locations(query: str, access_token: Optional[str] = None,
513
+ location_types: Optional[List[str]] = None, limit: int = 25) -> str:
514
+ """
515
+ Search for geographic targeting locations.
516
+
517
+ Args:
518
+ query: Search term for locations (e.g., "New York", "California", "Japan")
519
+ access_token: Meta API access token (optional - will use cached token if not provided)
520
+ location_types: Types of locations to search. Options: ['country', 'region', 'city', 'zip',
521
+ 'geo_market', 'electoral_district']. If not specified, searches all types.
522
+ limit: Maximum number of results to return (default: 25)
523
+
524
+ Returns:
525
+ JSON string containing location data with key, name, type, and geographic hierarchy information
526
+ """
527
+ if not query:
528
+ return json.dumps({"error": "No search query provided"}, indent=2)
529
+
530
+ endpoint = "search"
531
+ params = {
532
+ "type": "adgeolocation",
533
+ "q": query,
534
+ "limit": limit
535
+ }
536
+
537
+ if location_types:
538
+ params["location_types"] = json.dumps(location_types)
539
+
540
+ data = await make_api_request(endpoint, access_token, params)
541
+
542
+ return json.dumps(data, indent=2)