meta-ads-mcp 0.11.3__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.
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/PKG-INFO +1 -1
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/ads.py +15 -3
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/api.py +8 -1
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/openai_deep_research.py +0 -4
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/targeting.py +224 -18
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/pyproject.toml +1 -1
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_estimate_audience_size_e2e.py +102 -0
- meta_ads_mcp-0.11.3/tests/test_insights_actions_and_values.py → meta_ads_mcp-0.11.5/tests/test_insights_actions_and_values_e2e.py +102 -17
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/.gitignore +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/Dockerfile +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/LICENSE +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/README.md +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/RELEASE.md +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/examples/README.md +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/future_improvements.md +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/accounts.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/adsets.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/auth.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/callback_server.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/duplication.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/meta_ads_mcp/core/utils.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/requirements.txt +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/setup.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/smithery.yaml +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/README.md +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/e2e_account_info_search_issue.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_account_info_access_fix.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_account_search.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_budget_update.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_budget_update_e2e.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_dsa_beneficiary.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_dsa_integration.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_duplication.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_duplication_regression.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_dynamic_creatives.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_estimate_audience_size.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_get_account_pages.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_get_ad_creatives_fix.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_get_ad_image_quality_improvements.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_get_ad_image_regression.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_integration_openai_mcp.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_is_dynamic_creative_adset.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_mobile_app_adset_creation.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_mobile_app_adset_issue.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_openai.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_openai_mcp_deep_research.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_page_discovery.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_page_discovery_integration.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_targeting.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_targeting_search_e2e.py +0 -0
- {meta_ads_mcp-0.11.3 → meta_ads_mcp-0.11.5}/tests/test_update_ad_creative_id.py +0 -0
- {meta_ads_mcp-0.11.3 → 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.
|
|
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
|
|
@@ -635,15 +635,27 @@ async def upload_ad_image(
|
|
|
635
635
|
image_bytes = await try_multiple_download_methods(image_url)
|
|
636
636
|
except Exception as download_error:
|
|
637
637
|
return json.dumps({
|
|
638
|
-
"error": "
|
|
639
|
-
"
|
|
638
|
+
"error": "We couldn’t download the image from the link provided.",
|
|
639
|
+
"reason": "The server returned an error while trying to fetch the image.",
|
|
640
640
|
"image_url": image_url,
|
|
641
|
+
"details": str(download_error),
|
|
642
|
+
"suggestions": [
|
|
643
|
+
"Make sure the link is publicly reachable (no login, VPN, or IP restrictions).",
|
|
644
|
+
"If the image is hosted on a private app or server, move it to a public URL or a CDN and try again.",
|
|
645
|
+
"Verify the URL is correct and serves the actual image file."
|
|
646
|
+
]
|
|
641
647
|
}, indent=2)
|
|
642
648
|
|
|
643
649
|
if not image_bytes:
|
|
644
650
|
return json.dumps({
|
|
645
|
-
"error": "
|
|
651
|
+
"error": "We couldn’t access the image at the link you provided.",
|
|
652
|
+
"reason": "The image link doesn’t appear to be publicly accessible or didn’t return any data.",
|
|
646
653
|
"image_url": image_url,
|
|
654
|
+
"suggestions": [
|
|
655
|
+
"Double‑check that the link is public and does not require login, VPN, or IP allow‑listing.",
|
|
656
|
+
"If the image is stored in a private app (for example, a self‑hosted gallery), upload it to a public URL or a CDN and try again.",
|
|
657
|
+
"Confirm the URL is correct and points directly to an image file (e.g., .jpg, .png)."
|
|
658
|
+
]
|
|
647
659
|
}, indent=2)
|
|
648
660
|
|
|
649
661
|
import base64 # Local import
|
|
@@ -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
|
-
|
|
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):
|
|
@@ -315,8 +315,6 @@ async def search(
|
|
|
315
315
|
) -> str:
|
|
316
316
|
"""
|
|
317
317
|
Search through Meta Ads data and return matching record IDs.
|
|
318
|
-
|
|
319
|
-
This tool is required for OpenAI ChatGPT Deep Research integration.
|
|
320
318
|
It searches across ad accounts, campaigns, ads, pages, and businesses to find relevant records
|
|
321
319
|
based on the provided query.
|
|
322
320
|
|
|
@@ -371,8 +369,6 @@ async def fetch(
|
|
|
371
369
|
) -> str:
|
|
372
370
|
"""
|
|
373
371
|
Fetch complete record data by ID.
|
|
374
|
-
|
|
375
|
-
This tool is required for OpenAI ChatGPT Deep Research integration.
|
|
376
372
|
It retrieves the full data for a specific record identified by its ID.
|
|
377
373
|
|
|
378
374
|
Args:
|
|
@@ -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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
172
|
-
"
|
|
173
|
-
"
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
#
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|