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,431 @@
1
+ """Core API functionality for Meta Ads API."""
2
+
3
+ from typing import Any, Dict, Optional, Callable
4
+ import json
5
+ import hmac
6
+ import hashlib
7
+ import httpx
8
+ import asyncio
9
+ import functools
10
+ import os
11
+ from . import auth
12
+ from .auth import needs_authentication, auth_manager, start_callback_server, shutdown_callback_server
13
+ from .utils import logger
14
+
15
+ class McpToolError(Exception):
16
+ """Base class for MCP tool errors that must set isError: true.
17
+
18
+ Subclasses should be raised (not returned) from tool handlers.
19
+ meta_api_tool re-raises these so FastMCP sees them and sets
20
+ isError: true in the JSON-RPC response, which triggers the usage
21
+ credit refund in the Next.js proxy.
22
+ """
23
+ pass
24
+
25
+
26
+ def ensure_act_prefix(account_id: str) -> str:
27
+ """Ensure account_id has the 'act_' prefix required by Meta's Graph API."""
28
+ if account_id and not account_id.startswith("act_"):
29
+ return f"act_{account_id}"
30
+ return account_id
31
+
32
+
33
+ # Constants
34
+ META_GRAPH_API_VERSION = "v24.0"
35
+ META_GRAPH_API_BASE = f"https://graph.facebook.com/{META_GRAPH_API_VERSION}"
36
+ USER_AGENT = "meta-ads-mcp/1.0"
37
+
38
+ # Log key environment and configuration at startup
39
+ logger.info("Core API module initialized")
40
+ logger.info(f"Graph API Version: {META_GRAPH_API_VERSION}")
41
+ logger.info(f"META_APP_ID env var present: {'Yes' if os.environ.get('META_APP_ID') else 'No'}")
42
+ logger.info(f"META_APP_SECRET env var present (appsecret_proof will be {'enabled' if os.environ.get('META_APP_SECRET') else 'disabled'})")
43
+
44
+ class GraphAPIError(Exception):
45
+ """Exception raised for errors from the Graph API."""
46
+ def __init__(self, error_data: Dict[str, Any]):
47
+ self.error_data = error_data
48
+ self.message = error_data.get('message', 'Unknown Graph API error')
49
+ super().__init__(self.message)
50
+
51
+ # Log error details
52
+ logger.error(f"Graph API Error: {self.message}")
53
+ logger.debug(f"Error details: {error_data}")
54
+
55
+ # Check if this is an auth error (code 4 is rate limiting, NOT auth)
56
+ if "code" in error_data and error_data["code"] in [190, 102]:
57
+ logger.warning(f"Auth error detected (code: {error_data['code']}). Invalidating token.")
58
+ auth_manager.invalidate_token()
59
+ elif "code" in error_data and error_data["code"] == 4:
60
+ logger.warning(f"Rate limit error detected (code: 4, subcode: {error_data.get('error_subcode', 'N/A')}). Token is still valid -- NOT invalidating.")
61
+
62
+
63
+ def _log_meta_rate_limit_headers(headers: dict, endpoint: str) -> None:
64
+ """Log Meta's rate limit headers for observability (X-App-Usage, X-Business-Use-Case-Usage)."""
65
+ app_usage = headers.get("x-app-usage")
66
+ biz_usage = headers.get("x-business-use-case-usage")
67
+ ad_account_usage = headers.get("x-ad-account-usage")
68
+
69
+ if app_usage or biz_usage or ad_account_usage:
70
+ usage_data = {}
71
+ if app_usage:
72
+ try:
73
+ usage_data["app_usage"] = json.loads(app_usage)
74
+ except (json.JSONDecodeError, TypeError):
75
+ usage_data["app_usage_raw"] = str(app_usage)
76
+ if biz_usage:
77
+ try:
78
+ usage_data["business_use_case_usage"] = json.loads(biz_usage)
79
+ except (json.JSONDecodeError, TypeError):
80
+ usage_data["business_use_case_usage_raw"] = str(biz_usage)
81
+ if ad_account_usage:
82
+ try:
83
+ usage_data["ad_account_usage"] = json.loads(ad_account_usage)
84
+ except (json.JSONDecodeError, TypeError):
85
+ usage_data["ad_account_usage_raw"] = str(ad_account_usage)
86
+
87
+ # Warn at high usage levels (any field >= 80%)
88
+ is_high = False
89
+ for key, val in usage_data.items():
90
+ if isinstance(val, dict):
91
+ for metric, pct in val.items():
92
+ if isinstance(pct, (int, float)) and pct >= 80:
93
+ is_high = True
94
+ break
95
+
96
+ log_fn = logger.warning if is_high else logger.info
97
+ log_fn(f"meta_rate_limit_usage endpoint={endpoint} {json.dumps(usage_data)}")
98
+
99
+
100
+ async def make_api_request(
101
+ endpoint: str,
102
+ access_token: str,
103
+ params: Optional[Dict[str, Any]] = None,
104
+ method: str = "GET"
105
+ ) -> Dict[str, Any]:
106
+ """
107
+ Make a request to the Meta Graph API.
108
+
109
+ Args:
110
+ endpoint: API endpoint path (without base URL)
111
+ access_token: Meta API access token
112
+ params: Additional query parameters
113
+ method: HTTP method (GET, POST, DELETE)
114
+
115
+ Returns:
116
+ API response as a dictionary
117
+ """
118
+ # Validate access token before proceeding
119
+ if not access_token:
120
+ logger.error("API request attempted with blank access token")
121
+ return {
122
+ "error": {
123
+ "message": "Authentication Required",
124
+ "details": "A valid access token is required to access the Meta API",
125
+ "action_required": "Please authenticate first"
126
+ }
127
+ }
128
+
129
+ url = f"{META_GRAPH_API_BASE}/{endpoint}"
130
+
131
+ headers = {
132
+ "User-Agent": USER_AGENT,
133
+ }
134
+
135
+ request_params = params or {}
136
+ request_params["access_token"] = access_token
137
+
138
+ # Add appsecret_proof when META_APP_SECRET is configured.
139
+ # Required for system user tokens and recommended by Meta for all
140
+ # server-to-server API calls to verify token authenticity.
141
+ # See: https://developers.facebook.com/docs/graph-api/securing-requests/
142
+ app_secret = os.environ.get("META_APP_SECRET", "")
143
+ if app_secret and access_token:
144
+ request_params["appsecret_proof"] = hmac.new(
145
+ app_secret.encode("utf-8"),
146
+ access_token.encode("utf-8"),
147
+ hashlib.sha256,
148
+ ).hexdigest()
149
+
150
+ # Logging the request (masking token for security)
151
+ masked_params = {k: "***MASKED***" if k in ("access_token", "appsecret_proof") else v for k, v in request_params.items()}
152
+ logger.debug(f"API Request: {method} {url}")
153
+ logger.debug(f"Request params: {masked_params}")
154
+
155
+ # Check for app_id in params
156
+ app_id = auth_manager.app_id
157
+ logger.debug(f"Current app_id from auth_manager: {app_id}")
158
+
159
+ async with httpx.AsyncClient() as client:
160
+ try:
161
+ if method == "GET":
162
+ # For GET, JSON-encode dict/list params (e.g., targeting_spec) to proper strings
163
+ encoded_params = {}
164
+ for key, value in request_params.items():
165
+ if isinstance(value, (dict, list)):
166
+ encoded_params[key] = json.dumps(value)
167
+ else:
168
+ encoded_params[key] = value
169
+ response = await client.get(url, params=encoded_params, headers=headers, timeout=30.0)
170
+ elif method == "POST":
171
+ # For Meta API, POST requests need data, not JSON
172
+ if 'targeting' in request_params and isinstance(request_params['targeting'], dict):
173
+ # Convert targeting dict to string for the API
174
+ request_params['targeting'] = json.dumps(request_params['targeting'])
175
+
176
+ # Convert lists and dicts to JSON strings
177
+ for key, value in request_params.items():
178
+ if isinstance(value, (list, dict)):
179
+ request_params[key] = json.dumps(value)
180
+
181
+ logger.debug(f"POST params (prepared): {masked_params}")
182
+ response = await client.post(url, data=request_params, headers=headers, timeout=30.0)
183
+ elif method == "PUT":
184
+ # PUT for updates that Meta requires via PUT (e.g., creative_features_spec).
185
+ # Meta expects access_token as a query param, not in the body.
186
+ query_params = {}
187
+ body_params = {}
188
+ for key, value in request_params.items():
189
+ if key in ("access_token", "appsecret_proof"):
190
+ query_params[key] = value
191
+ elif isinstance(value, (list, dict)):
192
+ body_params[key] = json.dumps(value)
193
+ else:
194
+ body_params[key] = value
195
+ response = await client.put(url, params=query_params, data=body_params, headers=headers, timeout=30.0)
196
+ elif method == "DELETE":
197
+ response = await client.delete(url, params=request_params, headers=headers, timeout=30.0)
198
+ else:
199
+ raise ValueError(f"Unsupported HTTP method: {method}")
200
+
201
+ response.raise_for_status()
202
+ logger.debug(f"API Response status: {response.status_code}")
203
+
204
+ # Log Meta rate limit headers for observability
205
+ _log_meta_rate_limit_headers(response.headers, endpoint)
206
+
207
+ # Ensure the response is JSON and return it as a dictionary
208
+ try:
209
+ return response.json()
210
+ except json.JSONDecodeError:
211
+ # If not JSON, return text content in a structured format
212
+ return {
213
+ "text_response": response.text,
214
+ "status_code": response.status_code
215
+ }
216
+
217
+ except httpx.HTTPStatusError as e:
218
+ error_info = {}
219
+ try:
220
+ error_info = e.response.json()
221
+ except:
222
+ error_info = {"status_code": e.response.status_code, "text": e.response.text}
223
+
224
+ logger.error(f"HTTP Error: {e.response.status_code} - {error_info}")
225
+
226
+ # Log Meta rate limit headers even on errors
227
+ _log_meta_rate_limit_headers(e.response.headers, endpoint)
228
+
229
+ # Check for rate limit errors vs authentication errors.
230
+ # Code 4 is a rate limit (NOT auth) -- do NOT invalidate token.
231
+ if "error" in error_info:
232
+ error_obj = error_info.get("error", {})
233
+ error_code = error_obj.get("code") if isinstance(error_obj, dict) else None
234
+
235
+ if error_code == 4:
236
+ # Application-level rate limit -- token is still valid
237
+ logger.warning(
238
+ f"Facebook API rate limit (code=4, subcode={error_obj.get('error_subcode', 'N/A')}, "
239
+ f"msg={error_obj.get('error_user_msg', error_obj.get('message', 'N/A'))}). "
240
+ f"Token is still valid -- NOT invalidating."
241
+ )
242
+ elif error_code in [190, 102, 200, 10]:
243
+ logger.warning(f"Detected Facebook API auth error: {error_code}")
244
+ if error_code == 200 and "Provide valid app ID" in error_obj.get("message", ""):
245
+ logger.error("Meta API authentication configuration issue")
246
+ logger.error(f"Current app_id: {app_id}")
247
+ return {
248
+ "error": {
249
+ "message": "Meta API authentication configuration issue. Please check your app credentials.",
250
+ "original_error": error_obj.get("message"),
251
+ "code": error_code
252
+ }
253
+ }
254
+ auth_manager.invalidate_token()
255
+ elif e.response.status_code in [401, 403]:
256
+ logger.warning(f"Detected authentication error ({e.response.status_code})")
257
+ auth_manager.invalidate_token()
258
+ elif e.response.status_code in [401, 403]:
259
+ logger.warning(f"Detected authentication error ({e.response.status_code})")
260
+ auth_manager.invalidate_token()
261
+
262
+ # Include full details for technical users
263
+ full_response = {
264
+ "headers": dict(e.response.headers),
265
+ "status_code": e.response.status_code,
266
+ "url": str(e.response.url),
267
+ "reason": getattr(e.response, "reason_phrase", "Unknown reason"),
268
+ "request_method": e.request.method,
269
+ "request_url": str(e.request.url)
270
+ }
271
+
272
+ # Return a properly structured error object
273
+ return {
274
+ "error": {
275
+ "message": f"HTTP Error: {e.response.status_code}",
276
+ "details": error_info,
277
+ "full_response": full_response
278
+ }
279
+ }
280
+
281
+ except Exception as e:
282
+ logger.error(f"Request Error: {str(e)}")
283
+ return {"error": {"message": str(e)}}
284
+
285
+
286
+ # Generic wrapper for all Meta API tools
287
+ def meta_api_tool(func):
288
+ """Decorator for Meta API tools that handles authentication and error handling."""
289
+ @functools.wraps(func)
290
+ async def wrapper(*args, **kwargs):
291
+ try:
292
+ # Log function call
293
+ logger.debug(f"Function call: {func.__name__}")
294
+ logger.debug(f"Args: {args}")
295
+ # Log kwargs without sensitive info
296
+ safe_kwargs = {k: ('***TOKEN***' if k == 'access_token' else v) for k, v in kwargs.items()}
297
+ logger.debug(f"Kwargs: {safe_kwargs}")
298
+
299
+ # Log app ID information
300
+ app_id = auth_manager.app_id
301
+ logger.debug(f"Current app_id: {app_id}")
302
+ logger.debug(f"META_APP_ID env var: {os.environ.get('META_APP_ID')}")
303
+
304
+ # If access_token is not in kwargs or not kwargs['access_token'], try to get it from auth_manager
305
+ if 'access_token' not in kwargs or not kwargs['access_token']:
306
+ try:
307
+ access_token = await auth.get_current_access_token()
308
+ if access_token:
309
+ kwargs['access_token'] = access_token
310
+ logger.debug("Using access token from auth_manager")
311
+ else:
312
+ logger.warning("No access token available from auth_manager")
313
+ # Add more details about why token might be missing
314
+ if (auth_manager.app_id == "YOUR_META_APP_ID" or not auth_manager.app_id) and not auth_manager.use_pipeboard:
315
+ logger.error("TOKEN VALIDATION FAILED: No valid app_id configured")
316
+ logger.error("Please set META_APP_ID environment variable or configure in your code")
317
+ elif auth_manager.use_pipeboard:
318
+ logger.error("TOKEN VALIDATION FAILED: Pipeboard authentication enabled but no valid token available")
319
+ logger.error("Complete authentication via Pipeboard service or check PIPEBOARD_API_TOKEN")
320
+ else:
321
+ logger.error("Check logs above for detailed token validation failures")
322
+ except Exception as e:
323
+ logger.error(f"Error getting access token: {str(e)}")
324
+ # Add stack trace for better debugging
325
+ import traceback
326
+ logger.error(f"Stack trace: {traceback.format_exc()}")
327
+
328
+ # Final validation - if we still don't have a valid token, return authentication required
329
+ if 'access_token' not in kwargs or not kwargs['access_token']:
330
+ logger.warning("No access token available, authentication needed")
331
+
332
+ # Add more specific troubleshooting information
333
+ auth_url = auth_manager.get_auth_url()
334
+ app_id = auth_manager.app_id
335
+ using_pipeboard = auth_manager.use_pipeboard
336
+
337
+ logger.error("TOKEN VALIDATION SUMMARY:")
338
+ logger.error(f"- Current app_id: '{app_id}'")
339
+ logger.error(f"- Environment META_APP_ID: '{os.environ.get('META_APP_ID', 'Not set')}'")
340
+ logger.error(f"- Pipeboard API token configured: {'Yes' if os.environ.get('PIPEBOARD_API_TOKEN') else 'No'}")
341
+ logger.error(f"- Using Pipeboard authentication: {'Yes' if using_pipeboard else 'No'}")
342
+
343
+ # Check for common configuration issues - but only if not using Pipeboard
344
+ if not using_pipeboard and (app_id == "YOUR_META_APP_ID" or not app_id):
345
+ logger.error("ISSUE DETECTED: No valid Meta App ID configured")
346
+ logger.error("ACTION REQUIRED: Set META_APP_ID environment variable with a valid App ID")
347
+ elif using_pipeboard:
348
+ logger.error("ISSUE DETECTED: Pipeboard authentication configured but no valid token available")
349
+ logger.error("ACTION REQUIRED: Complete authentication via Pipeboard service")
350
+
351
+ # Provide different guidance based on authentication method
352
+ if using_pipeboard:
353
+ return json.dumps({
354
+ "error": {
355
+ "message": "Pipeboard Authentication Required",
356
+ "details": {
357
+ "description": "Your Pipeboard API token is invalid or has expired",
358
+ "action_required": "Update your Pipeboard token",
359
+ "setup_url": "https://pipeboard.co/setup",
360
+ "token_url": "https://pipeboard.co/api-tokens",
361
+ "configuration_status": {
362
+ "app_id_configured": bool(app_id) and app_id != "YOUR_META_APP_ID",
363
+ "pipeboard_enabled": True,
364
+ },
365
+ "troubleshooting": "Go to https://pipeboard.co/setup to verify your account setup, then visit https://pipeboard.co/api-tokens to obtain a new API token",
366
+ "setup_link": "[Verify your Pipeboard account setup](https://pipeboard.co/setup)",
367
+ "token_link": "[Get a new Pipeboard API token](https://pipeboard.co/api-tokens)"
368
+ }
369
+ }
370
+ }, indent=2)
371
+ else:
372
+ return json.dumps({
373
+ "error": {
374
+ "message": "Authentication Required",
375
+ "details": {
376
+ "description": "You need to authenticate with the Meta API before using this tool",
377
+ "action_required": "Please authenticate first",
378
+ "auth_url": auth_url,
379
+ "configuration_status": {
380
+ "app_id_configured": bool(app_id) and app_id != "YOUR_META_APP_ID",
381
+ "pipeboard_enabled": False,
382
+ },
383
+ "troubleshooting": "Check logs for TOKEN VALIDATION FAILED messages",
384
+ "markdown_link": f"[Click here to authenticate with Meta Ads API]({auth_url})"
385
+ }
386
+ }
387
+ }, indent=2)
388
+
389
+ # Call the original function
390
+ result = await func(*args, **kwargs)
391
+
392
+ # If the result is a string (JSON), try to parse it to check for errors
393
+ if isinstance(result, str):
394
+ try:
395
+ result_dict = json.loads(result)
396
+ if "error" in result_dict:
397
+ logger.error(f"Error in API response: {result_dict['error']}")
398
+ # If this is an app ID error, log more details
399
+ if isinstance(result_dict.get("details", {}).get("error", {}), dict):
400
+ error_obj = result_dict["details"]["error"]
401
+ if error_obj.get("code") == 200 and "Provide valid app ID" in error_obj.get("message", ""):
402
+ logger.error("Meta API authentication configuration issue")
403
+ logger.error(f"Current app_id: {app_id}")
404
+ # Replace the confusing error with a more user-friendly one
405
+ return json.dumps({
406
+ "error": {
407
+ "message": "Meta API Configuration Issue",
408
+ "details": {
409
+ "description": "Your Meta API app is not properly configured",
410
+ "action_required": "Check your META_APP_ID environment variable",
411
+ "current_app_id": app_id,
412
+ "original_error": error_obj.get("message")
413
+ }
414
+ }
415
+ }, indent=2)
416
+ except Exception:
417
+ # Not JSON or other parsing error, wrap it in a dictionary
418
+ return json.dumps({"data": result}, indent=2)
419
+
420
+ # If result is already a dictionary, ensure it's properly serialized
421
+ if isinstance(result, dict):
422
+ return json.dumps(result, indent=2)
423
+
424
+ return result
425
+ except McpToolError:
426
+ raise # Let FastMCP set isError: true and refund the usage credit
427
+ except Exception as e:
428
+ logger.error(f"Error in {func.__name__}: {str(e)}")
429
+ return json.dumps({"error": str(e)}, indent=2)
430
+
431
+ return wrapper