meta-ads-mcp 0.11.4__tar.gz → 0.11.5__tar.gz

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.
Files changed (78) hide show
  1. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/PKG-INFO +1 -1
  2. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/__init__.py +1 -1
  3. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/api.py +8 -1
  4. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/targeting.py +224 -18
  5. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/pyproject.toml +1 -1
  6. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_estimate_audience_size_e2e.py +102 -0
  7. meta_ads_mcp-0.11.4/tests/test_insights_actions_and_values.py → meta_ads_mcp-0.11.5/tests/test_insights_actions_and_values_e2e.py +102 -17
  8. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/.github/workflows/publish.yml +0 -0
  9. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/.github/workflows/test.yml +0 -0
  10. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/.gitignore +0 -0
  11. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/CUSTOM_META_APP.md +0 -0
  12. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/Dockerfile +0 -0
  13. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/LICENSE +0 -0
  14. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/LOCAL_INSTALLATION.md +0 -0
  15. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/META_API_NOTES.md +0 -0
  16. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/README.md +0 -0
  17. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/RELEASE.md +0 -0
  18. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/STREAMABLE_HTTP_SETUP.md +0 -0
  19. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/examples/README.md +0 -0
  20. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/examples/example_http_client.py +0 -0
  21. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/future_improvements.md +0 -0
  22. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/images/meta-ads-example.png +0 -0
  23. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_auth.sh +0 -0
  24. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/__main__.py +0 -0
  25. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/__init__.py +0 -0
  26. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/accounts.py +0 -0
  27. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/ads.py +0 -0
  28. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/ads_library.py +0 -0
  29. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/adsets.py +0 -0
  30. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/auth.py +0 -0
  31. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/authentication.py +0 -0
  32. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/budget_schedules.py +0 -0
  33. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/callback_server.py +0 -0
  34. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/campaigns.py +0 -0
  35. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/duplication.py +0 -0
  36. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  37. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/insights.py +0 -0
  38. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/openai_deep_research.py +0 -0
  39. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  40. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/reports.py +0 -0
  41. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/resources.py +0 -0
  42. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/server.py +0 -0
  43. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/utils.py +0 -0
  44. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/requirements.txt +0 -0
  45. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/setup.py +0 -0
  46. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/smithery.yaml +0 -0
  47. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/README.md +0 -0
  48. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/README_REGRESSION_TESTS.md +0 -0
  49. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/__init__.py +0 -0
  50. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/conftest.py +0 -0
  51. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/e2e_account_info_search_issue.py +0 -0
  52. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_account_info_access_fix.py +0 -0
  53. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_account_search.py +0 -0
  54. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_budget_update.py +0 -0
  55. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_budget_update_e2e.py +0 -0
  56. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_dsa_beneficiary.py +0 -0
  57. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_dsa_integration.py +0 -0
  58. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_duplication.py +0 -0
  59. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_duplication_regression.py +0 -0
  60. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_dynamic_creatives.py +0 -0
  61. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_estimate_audience_size.py +0 -0
  62. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_get_account_pages.py +0 -0
  63. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_get_ad_creatives_fix.py +0 -0
  64. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_get_ad_image_quality_improvements.py +0 -0
  65. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_get_ad_image_regression.py +0 -0
  66. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_http_transport.py +0 -0
  67. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_integration_openai_mcp.py +0 -0
  68. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_is_dynamic_creative_adset.py +0 -0
  69. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_mobile_app_adset_creation.py +0 -0
  70. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_mobile_app_adset_issue.py +0 -0
  71. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_openai.py +0 -0
  72. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_openai_mcp_deep_research.py +0 -0
  73. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_page_discovery.py +0 -0
  74. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_page_discovery_integration.py +0 -0
  75. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_targeting.py +0 -0
  76. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_targeting_search_e2e.py +0 -0
  77. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_update_ad_creative_id.py +0 -0
  78. {meta_ads_mcp-0.11.4 → meta_ads_mcp-0.11.5}/tests/test_upload_ad_image.py +0 -0
@@ -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.5
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
@@ -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.5"
10
10
 
11
11
  __all__ = [
12
12
  'get_ad_accounts',
@@ -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):
@@ -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:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "0.11.4"
7
+ version = "0.11.5"
8
8
  description = "Model Context Protocol (MCP) server for interacting with Meta Ads API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -207,6 +207,82 @@ class AudienceEstimationTester:
207
207
 
208
208
  return None
209
209
 
210
+ def test_pl_only_reachestimate_bounds(self) -> Dict[str, Any]:
211
+ """Verify PL-only reachestimate returns expected bounds and midpoint.
212
+
213
+ Prerequisite: Start server with fallback disabled so reachestimate is used directly.
214
+ Example:
215
+ export META_MCP_DISABLE_DELIVERY_FALLBACK=1
216
+ uv run python -m meta_ads_mcp --transport streamable-http --port 8080
217
+ """
218
+ print(f"\n🇵🇱 Testing PL-only reachestimate bounds (fallback disabled)")
219
+ local_account_id = "act_3182643988557192"
220
+ targeting_spec = {"geo_locations": {"countries": ["PL"]}}
221
+ expected_lower = 18600000
222
+ expected_upper = 21900000
223
+ expected_midpoint = 20250000
224
+
225
+ result = self._make_request("tools/call", {
226
+ "name": "estimate_audience_size",
227
+ "arguments": {
228
+ "account_id": local_account_id,
229
+ "targeting": targeting_spec,
230
+ "optimization_goal": "REACH"
231
+ }
232
+ })
233
+
234
+ if not result["success"]:
235
+ print(f" ❌ Request failed: {result.get('text', 'Unknown error')}")
236
+ return {"success": False, "error": result.get("text", "Unknown error")}
237
+
238
+ response_data = result["json"]["result"]
239
+ content = response_data.get("content", [{}])[0].get("text", "")
240
+ try:
241
+ parsed_content = json.loads(content)
242
+ except json.JSONDecodeError:
243
+ print(f" ❌ Invalid JSON response")
244
+ return {"success": False, "error": "Invalid JSON"}
245
+
246
+ error_info = self._check_for_errors(parsed_content)
247
+ if error_info["has_error"]:
248
+ print(f" ❌ API Error: {error_info['error_message']}")
249
+ return {"success": False, "error": error_info["error_message"], "error_format": error_info["format"]}
250
+
251
+ if not parsed_content.get("success", False):
252
+ print(f" ❌ Response indicates failure but no error message found")
253
+ return {"success": False, "error": "Unexpected failure"}
254
+
255
+ details = parsed_content.get("estimate_details", {}) or {}
256
+ lower = details.get("users_lower_bound")
257
+ upper = details.get("users_upper_bound")
258
+ midpoint = parsed_content.get("estimated_audience_size")
259
+ fallback_used = parsed_content.get("fallback_endpoint_used")
260
+
261
+ ok = (
262
+ lower == expected_lower and
263
+ upper == expected_upper and
264
+ midpoint == expected_midpoint and
265
+ (fallback_used is None)
266
+ )
267
+
268
+ if ok:
269
+ print(f" ✅ Bounds: {lower:,}–{upper:,}; midpoint: {midpoint:,}")
270
+ return {
271
+ "success": True,
272
+ "users_lower_bound": lower,
273
+ "users_upper_bound": upper,
274
+ "midpoint": midpoint
275
+ }
276
+ else:
277
+ print(f" ❌ Unexpected values: lower={lower}, upper={upper}, midpoint={midpoint}, fallback={fallback_used}")
278
+ return {
279
+ "success": False,
280
+ "users_lower_bound": lower,
281
+ "users_upper_bound": upper,
282
+ "midpoint": midpoint,
283
+ "fallback_endpoint_used": fallback_used
284
+ }
285
+
210
286
  def test_comprehensive_audience_estimation(self) -> Dict[str, Any]:
211
287
  """Test comprehensive audience estimation with complex targeting"""
212
288
 
@@ -491,6 +567,24 @@ class AudienceEstimationTester:
491
567
 
492
568
  results["invalid_targeting"] = self._parse_error_response(result, "Should handle invalid targeting")
493
569
 
570
+ # Test 4: Missing location in targeting (no geo_locations or custom audiences)
571
+ print(f" 🚫 Testing missing location in targeting")
572
+ result = self._make_request("tools/call", {
573
+ "name": "estimate_audience_size",
574
+ "arguments": {
575
+ "account_id": self.account_id,
576
+ # Interests present but no geo_locations and no custom_audiences
577
+ "targeting": {
578
+ "age_min": 18,
579
+ "age_max": 35,
580
+ "flexible_spec": [
581
+ {"interests": [{"id": "6003371567474"}]}
582
+ ]
583
+ }
584
+ }
585
+ })
586
+ results["missing_location"] = self._parse_error_response(result, "Should require a location or custom audience")
587
+
494
588
  return results
495
589
 
496
590
  def _parse_error_response(self, result: Dict[str, Any], description: str) -> Dict[str, Any]:
@@ -546,6 +640,13 @@ class AudienceEstimationTester:
546
640
  print("🔐 Using implicit authentication from server")
547
641
  print(f"🏢 Using account ID: {self.account_id}")
548
642
 
643
+ # Test 0: PL-only reachestimate bounds verification
644
+ print("\n" + "="*70)
645
+ print("📋 PHASE 0: PL-only reachestimate bounds verification (fallback disabled)")
646
+ print("="*70)
647
+ pl_only_results = self.test_pl_only_reachestimate_bounds()
648
+ pl_only_success = pl_only_results.get("success", False)
649
+
549
650
  # Test 1: Comprehensive Audience Estimation
550
651
  print("\n" + "="*70)
551
652
  print("📋 PHASE 1: Testing Comprehensive Audience Estimation")
@@ -595,6 +696,7 @@ class AudienceEstimationTester:
595
696
  print("="*70)
596
697
 
597
698
  all_tests = [
699
+ ("PL-only Reachestimate Bounds", pl_only_success),
598
700
  ("Comprehensive Estimation", comprehensive_success),
599
701
  ("Backwards Compatibility", compat_success),
600
702
  ("Optimization Goals", goals_success),
@@ -16,6 +16,7 @@ Test cases cover:
16
16
  import pytest
17
17
  import json
18
18
  import asyncio
19
+ import requests
19
20
  from unittest.mock import AsyncMock, patch, MagicMock
20
21
  from typing import Dict, Any, List
21
22
 
@@ -385,24 +386,108 @@ class TestInsightsActionsAndValues:
385
386
  assert 'lead' in action_value_types, "Lead action_value type not found"
386
387
 
387
388
 
388
- class TestInsightsActionsAndValuesIntegration:
389
- """Integration tests for actions and action_values functionality"""
389
+ @pytest.mark.e2e
390
+ @pytest.mark.skip(reason="E2E test - run manually only")
391
+ class TestInsightsActionsAndValuesE2E:
392
+ """E2E tests for actions and action_values via MCP HTTP server"""
390
393
 
391
- @pytest.mark.asyncio
392
- async def test_actions_workflow(self):
393
- """Test complete workflow with actions and action_values"""
394
-
395
- # This test would require actual API credentials and would be skipped in CI
396
- # It's included for manual testing with real data
397
- pytest.skip("Integration test requires real API credentials")
398
-
399
- # Example workflow:
400
- # 1. Get campaign insights with actions and action_values
401
- # 2. Verify the data structure
402
- # 3. Check that purchase data is present in actions and action_values
403
- # 4. Validate the values make sense
404
-
405
- pass
394
+ def __init__(self, base_url: str = "http://localhost:8080"):
395
+ self.base_url = base_url.rstrip('/')
396
+ self.endpoint = f"{self.base_url}/mcp/"
397
+ self.request_id = 1
398
+ # Default account from workspace rules
399
+ self.account_id = "act_701351919139047"
400
+
401
+ def _make_request(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
402
+ headers = {
403
+ "Content-Type": "application/json",
404
+ "Accept": "application/json, text/event-stream",
405
+ "User-Agent": "Insights-E2E-Test-Client/1.0"
406
+ }
407
+ payload = {
408
+ "jsonrpc": "2.0",
409
+ "method": method,
410
+ "id": self.request_id
411
+ }
412
+ if params:
413
+ payload["params"] = params
414
+ try:
415
+ resp = requests.post(self.endpoint, headers=headers, json=payload, timeout=30)
416
+ self.request_id += 1
417
+ return {
418
+ "status_code": resp.status_code,
419
+ "json": resp.json() if resp.status_code == 200 else None,
420
+ "text": resp.text,
421
+ "success": resp.status_code == 200
422
+ }
423
+ except requests.exceptions.RequestException as e:
424
+ return {"status_code": 0, "json": None, "text": str(e), "success": False, "error": str(e)}
425
+
426
+ def _check_for_errors(self, parsed_content: Dict[str, Any]) -> Dict[str, Any]:
427
+ if "data" in parsed_content:
428
+ data = parsed_content["data"]
429
+ if isinstance(data, dict) and 'error' in data:
430
+ return {"has_error": True, "error_message": data['error'], "format": "wrapped_dict"}
431
+ if isinstance(data, str):
432
+ try:
433
+ error_data = json.loads(data)
434
+ if 'error' in error_data:
435
+ return {"has_error": True, "error_message": error_data['error'], "format": "wrapped_json"}
436
+ except json.JSONDecodeError:
437
+ pass
438
+ if 'error' in parsed_content:
439
+ return {"has_error": True, "error_message": parsed_content['error'], "format": "direct"}
440
+ return {"has_error": False}
441
+
442
+ def test_tool_exists_and_has_required_fields(self):
443
+ result = self._make_request("tools/list", {})
444
+ assert result["success"], f"tools/list failed: {result.get('text', '')}"
445
+ tools = result["json"]["result"].get("tools", [])
446
+ tool = next((t for t in tools if t["name"] == "get_insights"), None)
447
+ assert tool is not None, "get_insights tool not found"
448
+ props = tool.get("inputSchema", {}).get("properties", {})
449
+ for req in ["object_id", "time_range", "breakdown", "level"]:
450
+ assert req in props, f"Missing parameter in schema: {req}"
451
+
452
+ def test_get_insights_account_level(self):
453
+ params = {
454
+ "name": "get_insights",
455
+ "arguments": {
456
+ "object_id": self.account_id,
457
+ "time_range": "last_30d",
458
+ "level": "account"
459
+ }
460
+ }
461
+ result = self._make_request("tools/call", params)
462
+ assert result["success"], f"tools/call failed: {result.get('text', '')}"
463
+ response_data = result["json"]["result"]
464
+ content = response_data.get("content", [{}])[0].get("text", "")
465
+ parsed = json.loads(content)
466
+ err = self._check_for_errors(parsed)
467
+ # Don't fail if auth or permissions block; just assert structure is parsable
468
+ if err["has_error"]:
469
+ assert isinstance(err["error_message"], (str, dict))
470
+ else:
471
+ # Expect data list on success
472
+ data = parsed.get("data") if isinstance(parsed, dict) else None
473
+ assert data is not None
474
+
475
+ def main():
476
+ tester = TestInsightsActionsAndValuesE2E()
477
+ print("🚀 Insights Actions E2E (manual)")
478
+ # Basic smoke run
479
+ try:
480
+ tester.test_tool_exists_and_has_required_fields()
481
+ print("✅ Tool schema ok")
482
+ tester.test_get_insights_account_level()
483
+ print("✅ Account-level insights request executed")
484
+ except AssertionError as e:
485
+ print(f"❌ Assertion failed: {e}")
486
+ except Exception as e:
487
+ print(f"❌ Error: {e}")
488
+
489
+ if __name__ == "__main__":
490
+ main()
406
491
 
407
492
 
408
493
  def extract_purchase_data(insights_data):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes