meta-ads-mcp 0.11.4__py3-none-any.whl → 0.11.6__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.
meta_ads_mcp/__init__.py CHANGED
@@ -6,7 +6,7 @@ This package provides a Meta Ads MCP integration
6
6
 
7
7
  from meta_ads_mcp.core.server import main
8
8
 
9
- __version__ = "0.11.4"
9
+ __version__ = "0.11.6"
10
10
 
11
11
  __all__ = [
12
12
  'get_ad_accounts',
meta_ads_mcp/core/api.py CHANGED
@@ -87,7 +87,14 @@ async def make_api_request(
87
87
  async with httpx.AsyncClient() as client:
88
88
  try:
89
89
  if method == "GET":
90
- response = await client.get(url, params=request_params, headers=headers, timeout=30.0)
90
+ # For GET, JSON-encode dict/list params (e.g., targeting_spec) to proper strings
91
+ encoded_params = {}
92
+ for key, value in request_params.items():
93
+ if isinstance(value, (dict, list)):
94
+ encoded_params[key] = json.dumps(value)
95
+ else:
96
+ encoded_params[key] = value
97
+ response = await client.get(url, params=encoded_params, headers=headers, timeout=30.0)
91
98
  elif method == "POST":
92
99
  # For Meta API, POST requests need data, not JSON
93
100
  if 'targeting' in request_params and isinstance(request_params['targeting'], dict):
@@ -30,7 +30,7 @@ if ENABLE_DUPLICATION:
30
30
  """
31
31
  Duplicate a Meta Ads campaign with all its ad sets and ads.
32
32
 
33
- **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
33
+ Recommended: Use this to run robust experiments.
34
34
 
35
35
  Args:
36
36
  campaign_id: Meta Ads campaign ID to duplicate
@@ -73,7 +73,7 @@ if ENABLE_DUPLICATION:
73
73
  """
74
74
  Duplicate a Meta Ads ad set with its ads.
75
75
 
76
- **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
76
+ Recommended: Use this to run robust experiments.
77
77
 
78
78
  Args:
79
79
  adset_id: Meta Ads ad set ID to duplicate
@@ -114,7 +114,7 @@ if ENABLE_DUPLICATION:
114
114
  """
115
115
  Duplicate a Meta Ads ad.
116
116
 
117
- **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
117
+ Recommended: Use this to run robust experiments.
118
118
 
119
119
  Args:
120
120
  ad_id: Meta Ads ad ID to duplicate
@@ -152,7 +152,7 @@ if ENABLE_DUPLICATION:
152
152
  """
153
153
  Duplicate a Meta Ads creative.
154
154
 
155
- **SUBSCRIPTION REQUIRED**: This feature requires an active subscription.
155
+ Recommended: Use this to run robust experiments.
156
156
 
157
157
  Args:
158
158
  creative_id: Meta Ads creative ID to duplicate
@@ -2,6 +2,7 @@
2
2
 
3
3
  import json
4
4
  from typing import Optional, List, Dict, Any
5
+ import os
5
6
  from .api import meta_api_tool, make_api_request
6
7
  from .server import mcp_server
7
8
 
@@ -147,6 +148,49 @@ async def estimate_audience_size(
147
148
  }
148
149
  }, indent=2)
149
150
 
151
+ # Preflight validation: require at least one location OR a custom audience
152
+ def _has_location_or_custom_audience(t: Dict[str, Any]) -> bool:
153
+ if not isinstance(t, dict):
154
+ return False
155
+ geo = t.get("geo_locations") or {}
156
+ if isinstance(geo, dict):
157
+ for key in [
158
+ "countries",
159
+ "regions",
160
+ "cities",
161
+ "zips",
162
+ "geo_markets",
163
+ "country_groups"
164
+ ]:
165
+ val = geo.get(key)
166
+ if isinstance(val, list) and len(val) > 0:
167
+ return True
168
+ # Top-level custom audiences
169
+ ca = t.get("custom_audiences")
170
+ if isinstance(ca, list) and len(ca) > 0:
171
+ return True
172
+ # Custom audiences within flexible_spec
173
+ flex = t.get("flexible_spec")
174
+ if isinstance(flex, list):
175
+ for spec in flex:
176
+ if isinstance(spec, dict):
177
+ ca_spec = spec.get("custom_audiences")
178
+ if isinstance(ca_spec, list) and len(ca_spec) > 0:
179
+ return True
180
+ return False
181
+
182
+ if not _has_location_or_custom_audience(targeting):
183
+ return json.dumps({
184
+ "error": "Missing target audience location",
185
+ "details": "Select at least one location in targeting.geo_locations or include a custom audience.",
186
+ "action_required": "Add geo_locations with countries/regions/cities/zips or include custom_audiences.",
187
+ "example": {
188
+ "geo_locations": {"countries": ["US"]},
189
+ "age_min": 25,
190
+ "age_max": 65
191
+ }
192
+ }, indent=2)
193
+
150
194
  # Build reach estimate request (using correct Meta API endpoint)
151
195
  endpoint = f"{account_id}/reachestimate"
152
196
  params = {
@@ -158,25 +202,153 @@ async def estimate_audience_size(
158
202
  try:
159
203
  data = await make_api_request(endpoint, access_token, params, method="GET")
160
204
 
205
+ # Surface Graph API errors directly for better diagnostics.
206
+ # If reachestimate fails, optionally attempt a fallback using delivery_estimate.
207
+ if isinstance(data, dict) and "error" in data:
208
+ # Special handling for Missing Target Audience Location error (subcode 1885364)
209
+ try:
210
+ err_wrapper = data.get("error", {})
211
+ details_obj = err_wrapper.get("details", {})
212
+ raw_err = details_obj.get("error", {}) if isinstance(details_obj, dict) else {}
213
+ if (
214
+ isinstance(raw_err, dict) and (
215
+ raw_err.get("error_subcode") == 1885364 or
216
+ raw_err.get("error_user_title") == "Missing Target Audience Location"
217
+ )
218
+ ):
219
+ return json.dumps({
220
+ "error": "Missing target audience location",
221
+ "details": raw_err.get("error_user_msg") or "Select at least one location, or choose a custom audience.",
222
+ "endpoint_used": f"{account_id}/reachestimate",
223
+ "action_required": "Add geo_locations with at least one of countries/regions/cities/zips or include custom_audiences.",
224
+ "blame_field_specs": raw_err.get("error_data", {}).get("blame_field_specs") if isinstance(raw_err.get("error_data"), dict) else None
225
+ }, indent=2)
226
+ except Exception:
227
+ pass
228
+ # Allow disabling fallback via environment variable
229
+ # Default: fallback disabled unless explicitly enabled by setting DISABLE flag to "0"
230
+ disable_fallback = os.environ.get("META_MCP_DISABLE_DELIVERY_FALLBACK", "1") == "1"
231
+ if disable_fallback:
232
+ return json.dumps({
233
+ "error": "Graph API returned an error for reachestimate",
234
+ "details": data.get("error"),
235
+ "endpoint_used": f"{account_id}/reachestimate",
236
+ "request_params": {
237
+ "has_targeting_spec": bool(targeting),
238
+ },
239
+ "note": "delivery_estimate fallback disabled via META_MCP_DISABLE_DELIVERY_FALLBACK"
240
+ }, indent=2)
241
+
242
+ # Try fallback to delivery_estimate endpoint
243
+ try:
244
+ fallback_endpoint = f"{account_id}/delivery_estimate"
245
+ fallback_params = {
246
+ "targeting_spec": json.dumps(targeting),
247
+ # Some API versions accept optimization_goal here
248
+ "optimization_goal": optimization_goal
249
+ }
250
+ fallback_data = await make_api_request(fallback_endpoint, access_token, fallback_params, method="GET")
251
+
252
+ # If fallback returns usable data, format similarly
253
+ if isinstance(fallback_data, dict) and "data" in fallback_data and len(fallback_data["data"]) > 0:
254
+ estimate_data = fallback_data["data"][0]
255
+ formatted_response = {
256
+ "success": True,
257
+ "account_id": account_id,
258
+ "targeting": targeting,
259
+ "optimization_goal": optimization_goal,
260
+ "estimated_audience_size": estimate_data.get("estimate_mau", 0),
261
+ "estimate_details": {
262
+ "monthly_active_users": estimate_data.get("estimate_mau", 0),
263
+ "daily_outcomes_curve": estimate_data.get("estimate_dau", []),
264
+ "bid_estimate": estimate_data.get("bid_estimates", {}),
265
+ "unsupported_targeting": estimate_data.get("unsupported_targeting", [])
266
+ },
267
+ "raw_response": fallback_data,
268
+ "fallback_endpoint_used": "delivery_estimate"
269
+ }
270
+ return json.dumps(formatted_response, indent=2)
271
+
272
+ # Fallback returned but not in expected format
273
+ return json.dumps({
274
+ "error": "Graph API returned an error for reachestimate; delivery_estimate fallback did not return usable data",
275
+ "reachestimate_error": data.get("error"),
276
+ "fallback_endpoint_used": "delivery_estimate",
277
+ "fallback_raw_response": fallback_data,
278
+ "endpoint_used": f"{account_id}/reachestimate",
279
+ "request_params": {
280
+ "has_targeting_spec": bool(targeting)
281
+ }
282
+ }, indent=2)
283
+ except Exception as _fallback_exc:
284
+ return json.dumps({
285
+ "error": "Graph API returned an error for reachestimate; delivery_estimate fallback also failed",
286
+ "reachestimate_error": data.get("error"),
287
+ "fallback_endpoint_used": "delivery_estimate",
288
+ "fallback_exception": str(_fallback_exc),
289
+ "endpoint_used": f"{account_id}/reachestimate",
290
+ "request_params": {
291
+ "has_targeting_spec": bool(targeting)
292
+ }
293
+ }, indent=2)
294
+
161
295
  # Format the response for easier consumption
162
- if "data" in data and len(data["data"]) > 0:
163
- estimate_data = data["data"][0]
164
- formatted_response = {
165
- "success": True,
166
- "account_id": account_id,
167
- "targeting": targeting,
168
- "optimization_goal": optimization_goal,
169
- "estimated_audience_size": estimate_data.get("estimate_mau", 0),
170
- "estimate_details": {
171
- "monthly_active_users": estimate_data.get("estimate_mau", 0),
172
- "daily_outcomes_curve": estimate_data.get("estimate_dau", []),
173
- "bid_estimate": estimate_data.get("bid_estimates", {}),
174
- "unsupported_targeting": estimate_data.get("unsupported_targeting", [])
175
- },
176
- "raw_response": data
177
- }
178
-
179
- return json.dumps(formatted_response, indent=2)
296
+ if "data" in data:
297
+ response_data = data["data"]
298
+ # Case 1: delivery_estimate-like list structure
299
+ if isinstance(response_data, list) and len(response_data) > 0:
300
+ estimate_data = response_data[0]
301
+ formatted_response = {
302
+ "success": True,
303
+ "account_id": account_id,
304
+ "targeting": targeting,
305
+ "optimization_goal": optimization_goal,
306
+ "estimated_audience_size": estimate_data.get("estimate_mau", 0),
307
+ "estimate_details": {
308
+ "monthly_active_users": estimate_data.get("estimate_mau", 0),
309
+ "daily_outcomes_curve": estimate_data.get("estimate_dau", []),
310
+ "bid_estimate": estimate_data.get("bid_estimates", {}),
311
+ "unsupported_targeting": estimate_data.get("unsupported_targeting", [])
312
+ },
313
+ "raw_response": data
314
+ }
315
+ return json.dumps(formatted_response, indent=2)
316
+ # Case 1b: explicit handling for empty list responses
317
+ if isinstance(response_data, list) and len(response_data) == 0:
318
+ return json.dumps({
319
+ "error": "No estimation data returned from Meta API",
320
+ "raw_response": data,
321
+ "debug_info": {
322
+ "response_keys": list(data.keys()) if isinstance(data, dict) else "not_a_dict",
323
+ "response_type": str(type(data)),
324
+ "endpoint_used": f"{account_id}/reachestimate"
325
+ }
326
+ }, indent=2)
327
+ # Case 2: reachestimate dict structure with bounds
328
+ if isinstance(response_data, dict):
329
+ lower = response_data.get("users_lower_bound", response_data.get("estimate_mau_lower_bound"))
330
+ upper = response_data.get("users_upper_bound", response_data.get("estimate_mau_upper_bound"))
331
+ estimate_ready = response_data.get("estimate_ready")
332
+ midpoint = None
333
+ try:
334
+ if isinstance(lower, (int, float)) and isinstance(upper, (int, float)):
335
+ midpoint = int((lower + upper) / 2)
336
+ except Exception:
337
+ midpoint = None
338
+ formatted_response = {
339
+ "success": True,
340
+ "account_id": account_id,
341
+ "targeting": targeting,
342
+ "optimization_goal": optimization_goal,
343
+ "estimated_audience_size": midpoint if midpoint is not None else 0,
344
+ "estimate_details": {
345
+ "users_lower_bound": lower,
346
+ "users_upper_bound": upper,
347
+ "estimate_ready": estimate_ready
348
+ },
349
+ "raw_response": data
350
+ }
351
+ return json.dumps(formatted_response, indent=2)
180
352
  else:
181
353
  return json.dumps({
182
354
  "error": "No estimation data returned from Meta API",
@@ -189,6 +361,40 @@ async def estimate_audience_size(
189
361
  }, indent=2)
190
362
 
191
363
  except Exception as e:
364
+ # Try fallback to delivery_estimate first when an exception occurs (unless disabled)
365
+ # Default: fallback disabled unless explicitly enabled by setting DISABLE flag to "0"
366
+ disable_fallback = os.environ.get("META_MCP_DISABLE_DELIVERY_FALLBACK", "1") == "1"
367
+ if not disable_fallback:
368
+ try:
369
+ fallback_endpoint = f"{account_id}/delivery_estimate"
370
+ fallback_params = {
371
+ "targeting_spec": json.dumps(targeting) if isinstance(targeting, dict) else targeting,
372
+ "optimization_goal": optimization_goal
373
+ }
374
+ fallback_data = await make_api_request(fallback_endpoint, access_token, fallback_params, method="GET")
375
+
376
+ if isinstance(fallback_data, dict) and "data" in fallback_data and len(fallback_data["data"]) > 0:
377
+ estimate_data = fallback_data["data"][0]
378
+ formatted_response = {
379
+ "success": True,
380
+ "account_id": account_id,
381
+ "targeting": targeting,
382
+ "optimization_goal": optimization_goal,
383
+ "estimated_audience_size": estimate_data.get("estimate_mau", 0),
384
+ "estimate_details": {
385
+ "monthly_active_users": estimate_data.get("estimate_mau", 0),
386
+ "daily_outcomes_curve": estimate_data.get("estimate_dau", []),
387
+ "bid_estimate": estimate_data.get("bid_estimates", {}),
388
+ "unsupported_targeting": estimate_data.get("unsupported_targeting", [])
389
+ },
390
+ "raw_response": fallback_data,
391
+ "fallback_endpoint_used": "delivery_estimate"
392
+ }
393
+ return json.dumps(formatted_response, indent=2)
394
+ except Exception as _fallback_exc:
395
+ # If fallback also fails, proceed to detailed error handling below
396
+ pass
397
+
192
398
  # Check if this is the specific Business Manager system user permission error
193
399
  error_str = str(e)
194
400
  if "100" in error_str and "33" in error_str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-ads-mcp
3
- Version: 0.11.4
3
+ Version: 0.11.6
4
4
  Summary: Model Context Protocol (MCP) server for interacting with Meta Ads API
5
5
  Project-URL: Homepage, https://github.com/pipeboard-co/meta-ads-mcp
6
6
  Project-URL: Bug Tracker, https://github.com/pipeboard-co/meta-ads-mcp/issues
@@ -1,17 +1,17 @@
1
- meta_ads_mcp/__init__.py,sha256=4ruafDZ8uRX6-M1cFTV4c2wPA_D37Y3cpfLDKY5jQ4g,1477
1
+ meta_ads_mcp/__init__.py,sha256=gdm32l1E4vgI6a8WI_vbxqmq9Tfvl4g4TeWUcZkSWbg,1477
2
2
  meta_ads_mcp/__main__.py,sha256=XaQt3iXftG_7f0Zu7Wop9SeFgrD2WBn0EQOaPMc27d8,207
3
3
  meta_ads_mcp/core/__init__.py,sha256=IEJtqpyUo0CZSUWeQPljQ-D2vKorTFwXnpBQWSi1hIM,1819
4
4
  meta_ads_mcp/core/accounts.py,sha256=7Zoqq0zMIJi_Xsxe9-_b3EYx-UTeieJJvO7HxVRuUS0,4327
5
5
  meta_ads_mcp/core/ads.py,sha256=5ulQ4p3lLo1_sIiAoMdyeo0O2y7Yq2zSDuvqkNiqX1c,61376
6
6
  meta_ads_mcp/core/ads_library.py,sha256=smGz9FhM6RIUjlQT4Jv1BaZmXahGdK21eRCB7QMhK-4,3228
7
7
  meta_ads_mcp/core/adsets.py,sha256=3Ok3EwPTReKshtsVs4gRMlws6LMTUJTb4ZeGPPM8JR8,16570
8
- meta_ads_mcp/core/api.py,sha256=58F6fRrg3ny_vuLgHu1ZN1yueNAxVXz_nEcF6JlxlWk,16469
8
+ meta_ads_mcp/core/api.py,sha256=dR1h3sTnSmIdEz8OSttqELA0YxBkbjMnSflgM5LsJ1A,16858
9
9
  meta_ads_mcp/core/auth.py,sha256=l_IvejK2KYXg8yhBiP0ifE6mGwJ6ZujqYQbVw1KOUME,23649
10
10
  meta_ads_mcp/core/authentication.py,sha256=ftoKec1HpfYCCVYIVKUD3ezvVAk6n_CJlBuePn8fzpM,10547
11
11
  meta_ads_mcp/core/budget_schedules.py,sha256=FVyJuKbjUE4cmtlPJEbIwpN6JmU-1WQjc7io1y5JaHE,2911
12
12
  meta_ads_mcp/core/callback_server.py,sha256=LIAJv9DW--83kdZ7VWWZal8xEprYjRZ8iug4rMczYbQ,9372
13
13
  meta_ads_mcp/core/campaigns.py,sha256=m24epO1QmyBVBXfqHIFZAi6sRCTlOGLQckjy0azqkvo,14319
14
- meta_ads_mcp/core/duplication.py,sha256=wtLLuO5ohbuXO79g-CRdS-_yNLUCZwRkJxukhufdH9g,19024
14
+ meta_ads_mcp/core/duplication.py,sha256=ae9GisFg9MMXbgX8zqb6ekEFIv7DGNCMV-lwkDXSL-c,18928
15
15
  meta_ads_mcp/core/http_auth_integration.py,sha256=lGpKhfzJcyWugBcYEvypY-qnlt-3UDBLqh7xAUH0DGw,12473
16
16
  meta_ads_mcp/core/insights.py,sha256=O8eldZG7wi46_heVgnOGiHPK3M_YNzvfT81wZQt9m0Q,4710
17
17
  meta_ads_mcp/core/openai_deep_research.py,sha256=68ayGopnBSPEYhN9R1sFvTXtyWtM0lji9aWS3uSXnLY,18649
@@ -19,10 +19,10 @@ meta_ads_mcp/core/pipeboard_auth.py,sha256=vv0yc4RBcGOm7VOovud-QNV1JmvBF-njOKICz
19
19
  meta_ads_mcp/core/reports.py,sha256=2pXYCCjYc3MZ8GlrbSHND436W62WlbfbtMll1dfJdqE,5750
20
20
  meta_ads_mcp/core/resources.py,sha256=-zIIfZulpo76vcKv6jhAlQq91cR2SZ3cjYZt3ek3x0w,1236
21
21
  meta_ads_mcp/core/server.py,sha256=9SlgM_qvdlxo24ctnZzLgW1e1nfAspCSx3YyJQkKP64,17856
22
- meta_ads_mcp/core/targeting.py,sha256=-QziS2MvTWzM02pwiUtId4sblWURd3UYPR_YYfVuiUk,13913
22
+ meta_ads_mcp/core/targeting.py,sha256=d2uLWbIEtucRuTgwZEdtVKLDZJgaxQ1lDtZ0ZgkBJC4,25150
23
23
  meta_ads_mcp/core/utils.py,sha256=ytj41yC5SqduLrAiZYBSd6OUwlJRaIClTwnnYKpNFds,9387
24
- meta_ads_mcp-0.11.4.dist-info/METADATA,sha256=pI9J1eNS624ATvgJQKdrYDSldUQ7MKRan1DDEjNo5IM,24245
25
- meta_ads_mcp-0.11.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
26
- meta_ads_mcp-0.11.4.dist-info/entry_points.txt,sha256=Dv2RkoBjRJBqj6CyhwqGIiwPCD-SCL1-7B9-zmVRuv0,57
27
- meta_ads_mcp-0.11.4.dist-info/licenses/LICENSE,sha256=E2d762fbhwKRYn8o7J6Szr6vyBPrHVDlK3jbHPx-d84,3851
28
- meta_ads_mcp-0.11.4.dist-info/RECORD,,
24
+ meta_ads_mcp-0.11.6.dist-info/METADATA,sha256=8ZciePGi_K5-6VYwh7D50rbJDjPVLlyLHpAv0JWFWAA,24245
25
+ meta_ads_mcp-0.11.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
26
+ meta_ads_mcp-0.11.6.dist-info/entry_points.txt,sha256=Dv2RkoBjRJBqj6CyhwqGIiwPCD-SCL1-7B9-zmVRuv0,57
27
+ meta_ads_mcp-0.11.6.dist-info/licenses/LICENSE,sha256=E2d762fbhwKRYn8o7J6Szr6vyBPrHVDlK3jbHPx-d84,3851
28
+ meta_ads_mcp-0.11.6.dist-info/RECORD,,