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.
- meta_ads_mcp/__init__.py +79 -0
- meta_ads_mcp/__main__.py +10 -0
- meta_ads_mcp/core/__init__.py +55 -0
- meta_ads_mcp/core/accounts.py +141 -0
- meta_ads_mcp/core/ads.py +2751 -0
- meta_ads_mcp/core/ads_library.py +74 -0
- meta_ads_mcp/core/adsets.py +666 -0
- meta_ads_mcp/core/api.py +431 -0
- meta_ads_mcp/core/auth.py +567 -0
- meta_ads_mcp/core/authentication.py +207 -0
- meta_ads_mcp/core/budget_schedules.py +70 -0
- meta_ads_mcp/core/callback_server.py +256 -0
- meta_ads_mcp/core/campaigns.py +379 -0
- meta_ads_mcp/core/duplication.py +523 -0
- meta_ads_mcp/core/http_auth_integration.py +307 -0
- meta_ads_mcp/core/insights.py +161 -0
- meta_ads_mcp/core/mcc.py +232 -0
- meta_ads_mcp/core/openai_deep_research.py +418 -0
- meta_ads_mcp/core/pipeboard_auth.py +510 -0
- meta_ads_mcp/core/reports.py +135 -0
- meta_ads_mcp/core/resources.py +46 -0
- meta_ads_mcp/core/server.py +391 -0
- meta_ads_mcp/core/targeting.py +542 -0
- meta_ads_mcp/core/utils.py +225 -0
- meta_ads_mcp/settings.py +33 -0
- meta_ads_mcp_python-1.0.79.dist-info/METADATA +187 -0
- meta_ads_mcp_python-1.0.79.dist-info/RECORD +29 -0
- meta_ads_mcp_python-1.0.79.dist-info/WHEEL +4 -0
- meta_ads_mcp_python-1.0.79.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Authentication with Meta Ads API via pipeboard.co."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import requests
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import platform
|
|
9
|
+
from typing import Optional, Dict, Any
|
|
10
|
+
from .utils import logger
|
|
11
|
+
|
|
12
|
+
# Base URL for pipeboard API
|
|
13
|
+
PIPEBOARD_API_BASE = "https://pipeboard.co/api"
|
|
14
|
+
|
|
15
|
+
# Debug message about API base URL
|
|
16
|
+
logger.info(f"Pipeboard API base URL: {PIPEBOARD_API_BASE}")
|
|
17
|
+
|
|
18
|
+
class TokenInfo:
|
|
19
|
+
"""Stores token information including expiration"""
|
|
20
|
+
def __init__(self, access_token: str, expires_at: Optional[str] = None, token_type: Optional[str] = None):
|
|
21
|
+
self.access_token = access_token
|
|
22
|
+
self.expires_at = expires_at
|
|
23
|
+
self.token_type = token_type
|
|
24
|
+
self.created_at = int(time.time())
|
|
25
|
+
logger.debug(f"TokenInfo created. Expires at: {expires_at if expires_at else 'Not specified'}")
|
|
26
|
+
|
|
27
|
+
def is_expired(self) -> bool:
|
|
28
|
+
"""Check if the token is expired"""
|
|
29
|
+
if not self.expires_at:
|
|
30
|
+
logger.debug("No expiration date set for token, assuming not expired")
|
|
31
|
+
return False # If no expiration is set, assume it's not expired
|
|
32
|
+
|
|
33
|
+
# Parse ISO 8601 date format to timestamp
|
|
34
|
+
try:
|
|
35
|
+
# Convert the expires_at string to a timestamp
|
|
36
|
+
# Format is like "2023-12-31T23:59:59.999Z" or "2023-12-31T23:59:59.999+00:00"
|
|
37
|
+
from datetime import datetime
|
|
38
|
+
|
|
39
|
+
# Remove the Z suffix if present and handle +00:00 format
|
|
40
|
+
expires_at_str = self.expires_at
|
|
41
|
+
if expires_at_str.endswith('Z'):
|
|
42
|
+
expires_at_str = expires_at_str[:-1] # Remove Z
|
|
43
|
+
|
|
44
|
+
# Handle microseconds if present
|
|
45
|
+
if '.' in expires_at_str:
|
|
46
|
+
datetime_format = "%Y-%m-%dT%H:%M:%S.%f"
|
|
47
|
+
else:
|
|
48
|
+
datetime_format = "%Y-%m-%dT%H:%M:%S"
|
|
49
|
+
|
|
50
|
+
# Handle timezone offset
|
|
51
|
+
timezone_offset = "+00:00"
|
|
52
|
+
if "+" in expires_at_str:
|
|
53
|
+
expires_at_str, timezone_offset = expires_at_str.split("+")
|
|
54
|
+
timezone_offset = "+" + timezone_offset
|
|
55
|
+
|
|
56
|
+
# Parse the datetime without timezone info
|
|
57
|
+
expires_datetime = datetime.strptime(expires_at_str, datetime_format)
|
|
58
|
+
|
|
59
|
+
# Convert to timestamp (assume UTC)
|
|
60
|
+
expires_timestamp = expires_datetime.timestamp()
|
|
61
|
+
current_time = time.time()
|
|
62
|
+
|
|
63
|
+
# Check if token is expired and log result
|
|
64
|
+
is_expired = current_time > expires_timestamp
|
|
65
|
+
time_diff = expires_timestamp - current_time
|
|
66
|
+
if is_expired:
|
|
67
|
+
logger.debug(f"Token is expired! Current time: {datetime.fromtimestamp(current_time)}, "
|
|
68
|
+
f"Expires at: {datetime.fromtimestamp(expires_timestamp)}, "
|
|
69
|
+
f"Expired {abs(time_diff):.0f} seconds ago")
|
|
70
|
+
else:
|
|
71
|
+
logger.debug(f"Token is still valid. Expires at: {datetime.fromtimestamp(expires_timestamp)}, "
|
|
72
|
+
f"Time remaining: {time_diff:.0f} seconds")
|
|
73
|
+
|
|
74
|
+
return is_expired
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error(f"Error parsing expiration date: {e}")
|
|
77
|
+
# Log the actual value to help diagnose format issues
|
|
78
|
+
logger.error(f"Invalid expires_at value: '{self.expires_at}'")
|
|
79
|
+
# Log detailed error information
|
|
80
|
+
import traceback
|
|
81
|
+
logger.error(f"Traceback: {traceback.format_exc()}")
|
|
82
|
+
return False # If we can't parse the date, assume it's not expired
|
|
83
|
+
|
|
84
|
+
def serialize(self) -> Dict[str, Any]:
|
|
85
|
+
"""Convert to a dictionary for storage"""
|
|
86
|
+
return {
|
|
87
|
+
"access_token": self.access_token,
|
|
88
|
+
"expires_at": self.expires_at,
|
|
89
|
+
"token_type": self.token_type,
|
|
90
|
+
"created_at": self.created_at
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def deserialize(cls, data: Dict[str, Any]) -> 'TokenInfo':
|
|
95
|
+
"""Create from a stored dictionary"""
|
|
96
|
+
logger.debug(f"Deserializing token data with keys: {', '.join(data.keys())}")
|
|
97
|
+
if 'expires_at' in data:
|
|
98
|
+
logger.debug(f"Token expires_at from cache: {data['expires_at']}")
|
|
99
|
+
|
|
100
|
+
token = cls(
|
|
101
|
+
access_token=data.get("access_token", ""),
|
|
102
|
+
expires_at=data.get("expires_at"),
|
|
103
|
+
token_type=data.get("token_type")
|
|
104
|
+
)
|
|
105
|
+
token.created_at = data.get("created_at", int(time.time()))
|
|
106
|
+
return token
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PipeboardAuthManager:
|
|
110
|
+
"""Manages authentication with Meta APIs via pipeboard.co"""
|
|
111
|
+
def __init__(self):
|
|
112
|
+
self.api_token = os.environ.get("PIPEBOARD_API_TOKEN", "")
|
|
113
|
+
logger.debug(f"PipeboardAuthManager initialized with API token: {self.api_token[:5]}..." if self.api_token else "No API token")
|
|
114
|
+
if self.api_token:
|
|
115
|
+
logger.info("Pipeboard authentication enabled. Will use pipeboard.co for Meta authentication.")
|
|
116
|
+
else:
|
|
117
|
+
logger.info("Pipeboard authentication not enabled. Set PIPEBOARD_API_TOKEN environment variable to enable.")
|
|
118
|
+
self.token_info = None
|
|
119
|
+
# Note: Token caching is disabled to always fetch fresh tokens from Pipeboard
|
|
120
|
+
|
|
121
|
+
def _get_token_cache_path(self) -> Path:
|
|
122
|
+
"""Get the platform-specific path for token cache file"""
|
|
123
|
+
if platform.system() == "Windows":
|
|
124
|
+
base_path = Path(os.environ.get("APPDATA", ""))
|
|
125
|
+
elif platform.system() == "Darwin": # macOS
|
|
126
|
+
base_path = Path.home() / "Library" / "Application Support"
|
|
127
|
+
else: # Assume Linux/Unix
|
|
128
|
+
base_path = Path.home() / ".config"
|
|
129
|
+
|
|
130
|
+
# Create directory if it doesn't exist
|
|
131
|
+
cache_dir = base_path / "meta-ads-mcp"
|
|
132
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
|
|
134
|
+
cache_path = cache_dir / "pipeboard_token_cache.json"
|
|
135
|
+
logger.debug(f"Token cache path: {cache_path}")
|
|
136
|
+
return cache_path
|
|
137
|
+
|
|
138
|
+
def _load_cached_token(self) -> bool:
|
|
139
|
+
"""Load token from cache if available"""
|
|
140
|
+
cache_path = self._get_token_cache_path()
|
|
141
|
+
|
|
142
|
+
if not cache_path.exists():
|
|
143
|
+
logger.debug(f"Token cache file not found at {cache_path}")
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
with open(cache_path, "r") as f:
|
|
148
|
+
logger.debug(f"Reading token cache from {cache_path}")
|
|
149
|
+
data = json.load(f)
|
|
150
|
+
|
|
151
|
+
# Validate the cached data structure
|
|
152
|
+
required_fields = ["access_token"]
|
|
153
|
+
if not all(field in data for field in required_fields):
|
|
154
|
+
logger.warning("Cached token data is missing required fields")
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
# Check if the token looks valid (basic format check)
|
|
158
|
+
if not data.get("access_token") or len(data["access_token"]) < 20:
|
|
159
|
+
logger.warning("Cached token appears malformed")
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
self.token_info = TokenInfo.deserialize(data)
|
|
163
|
+
|
|
164
|
+
# Log token details (partial token for security)
|
|
165
|
+
masked_token = self.token_info.access_token[:10] + "..." + self.token_info.access_token[-5:] if self.token_info.access_token else "None"
|
|
166
|
+
logger.debug(f"Loaded token: {masked_token}")
|
|
167
|
+
|
|
168
|
+
# Check if token is expired
|
|
169
|
+
if self.token_info.is_expired():
|
|
170
|
+
logger.info("Cached token is expired, removing cache file")
|
|
171
|
+
# Remove the expired cache file
|
|
172
|
+
try:
|
|
173
|
+
cache_path.unlink()
|
|
174
|
+
logger.info(f"Removed expired token cache: {cache_path}")
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.warning(f"Could not remove expired cache file: {e}")
|
|
177
|
+
self.token_info = None
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
# Additional validation: check if token is too old (more than 60 days)
|
|
181
|
+
current_time = int(time.time())
|
|
182
|
+
if self.token_info.created_at and (current_time - self.token_info.created_at) > (60 * 24 * 3600):
|
|
183
|
+
logger.warning("Cached token is too old (more than 60 days), removing cache file")
|
|
184
|
+
try:
|
|
185
|
+
cache_path.unlink()
|
|
186
|
+
logger.info(f"Removed old token cache: {cache_path}")
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.warning(f"Could not remove old cache file: {e}")
|
|
189
|
+
self.token_info = None
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
logger.info(f"Loaded cached token (expires at {self.token_info.expires_at})")
|
|
193
|
+
return True
|
|
194
|
+
except json.JSONDecodeError as e:
|
|
195
|
+
logger.error(f"Error parsing token cache file: {e}")
|
|
196
|
+
logger.debug("Token cache file might be corrupted, trying to read raw content")
|
|
197
|
+
try:
|
|
198
|
+
with open(cache_path, "r") as f:
|
|
199
|
+
raw_content = f.read()
|
|
200
|
+
logger.debug(f"Raw cache file content (first 100 chars): {raw_content[:100]}")
|
|
201
|
+
except Exception as e2:
|
|
202
|
+
logger.error(f"Could not read raw cache file: {e2}")
|
|
203
|
+
# If there's any error reading the cache, try to remove the corrupted file
|
|
204
|
+
try:
|
|
205
|
+
cache_path.unlink()
|
|
206
|
+
logger.info(f"Removed corrupted token cache: {cache_path}")
|
|
207
|
+
except Exception as cleanup_error:
|
|
208
|
+
logger.warning(f"Could not remove corrupted cache file: {cleanup_error}")
|
|
209
|
+
return False
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.error(f"Error loading cached token: {e}")
|
|
212
|
+
# If there's any error reading the cache, try to remove the corrupted file
|
|
213
|
+
try:
|
|
214
|
+
cache_path.unlink()
|
|
215
|
+
logger.info(f"Removed corrupted token cache: {cache_path}")
|
|
216
|
+
except Exception as cleanup_error:
|
|
217
|
+
logger.warning(f"Could not remove corrupted cache file: {cleanup_error}")
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
def _save_token_to_cache(self) -> None:
|
|
221
|
+
"""Save token to cache file"""
|
|
222
|
+
if not self.token_info:
|
|
223
|
+
logger.debug("No token to save to cache")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
cache_path = self._get_token_cache_path()
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
token_data = self.token_info.serialize()
|
|
230
|
+
logger.debug(f"Saving token to cache. Expires at: {token_data.get('expires_at')}")
|
|
231
|
+
|
|
232
|
+
with open(cache_path, "w") as f:
|
|
233
|
+
json.dump(token_data, f)
|
|
234
|
+
logger.info(f"Token cached at: {cache_path}")
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.error(f"Error saving token to cache: {e}")
|
|
237
|
+
|
|
238
|
+
def initiate_auth_flow(self) -> Dict[str, str]:
|
|
239
|
+
"""
|
|
240
|
+
Initiate the Meta OAuth flow via pipeboard.co
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Dict with loginUrl and status info
|
|
244
|
+
"""
|
|
245
|
+
if not self.api_token:
|
|
246
|
+
logger.error("No PIPEBOARD_API_TOKEN environment variable set")
|
|
247
|
+
raise ValueError("No PIPEBOARD_API_TOKEN environment variable set")
|
|
248
|
+
|
|
249
|
+
# Exactly match the format used in meta_auth_test.sh
|
|
250
|
+
url = f"{PIPEBOARD_API_BASE}/meta/auth?api_token={self.api_token}"
|
|
251
|
+
headers = {
|
|
252
|
+
"Content-Type": "application/json"
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
logger.info(f"Initiating auth flow with POST request to {url}")
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
# Make the POST request exactly as in the working meta_auth_test.sh script
|
|
259
|
+
response = requests.post(url, headers=headers)
|
|
260
|
+
logger.info(f"Auth flow response status: {response.status_code}")
|
|
261
|
+
|
|
262
|
+
# Better error handling
|
|
263
|
+
if response.status_code != 200:
|
|
264
|
+
logger.error(f"Auth flow error: HTTP {response.status_code}")
|
|
265
|
+
error_text = response.text if response.text else "No response content"
|
|
266
|
+
logger.error(f"Response content: {error_text}")
|
|
267
|
+
if response.status_code == 404:
|
|
268
|
+
raise ValueError(f"Pipeboard API endpoint not found. Check if the server is running at {PIPEBOARD_API_BASE}")
|
|
269
|
+
elif response.status_code == 401:
|
|
270
|
+
raise ValueError(f"Unauthorized: Invalid API token. Check your PIPEBOARD_API_TOKEN.")
|
|
271
|
+
|
|
272
|
+
response.raise_for_status()
|
|
273
|
+
|
|
274
|
+
# Parse the response
|
|
275
|
+
try:
|
|
276
|
+
data = response.json()
|
|
277
|
+
logger.info(f"Received response keys: {', '.join(data.keys())}")
|
|
278
|
+
except json.JSONDecodeError:
|
|
279
|
+
logger.error(f"Could not parse JSON response: {response.text}")
|
|
280
|
+
raise ValueError(f"Invalid JSON response from auth endpoint: {response.text[:100]}")
|
|
281
|
+
|
|
282
|
+
# Log auth flow response (without sensitive information)
|
|
283
|
+
if 'loginUrl' in data:
|
|
284
|
+
logger.info(f"Auth flow initiated successfully with login URL: {data['loginUrl'][:30]}...")
|
|
285
|
+
else:
|
|
286
|
+
logger.warning(f"Auth flow response missing loginUrl field. Response keys: {', '.join(data.keys())}")
|
|
287
|
+
|
|
288
|
+
return data
|
|
289
|
+
except requests.exceptions.ConnectionError as e:
|
|
290
|
+
logger.error(f"Connection error to Pipeboard: {e}")
|
|
291
|
+
logger.debug(f"Attempting to connect to: {PIPEBOARD_API_BASE}")
|
|
292
|
+
raise
|
|
293
|
+
except requests.exceptions.Timeout as e:
|
|
294
|
+
logger.error(f"Timeout connecting to Pipeboard: {e}")
|
|
295
|
+
raise
|
|
296
|
+
except requests.exceptions.RequestException as e:
|
|
297
|
+
logger.error(f"Error initiating auth flow: {e}")
|
|
298
|
+
raise
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logger.error(f"Unexpected error initiating auth flow: {e}")
|
|
301
|
+
raise
|
|
302
|
+
|
|
303
|
+
def get_access_token(self, force_refresh: bool = False) -> Optional[str]:
|
|
304
|
+
"""
|
|
305
|
+
Get the current access token, refreshing if necessary or if forced
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
force_refresh: Force token refresh even if cached token exists
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Access token if available, None otherwise
|
|
312
|
+
"""
|
|
313
|
+
# First check if API token is configured
|
|
314
|
+
if not self.api_token:
|
|
315
|
+
logger.error("TOKEN VALIDATION FAILED: No Pipeboard API token configured")
|
|
316
|
+
logger.error("Please set PIPEBOARD_API_TOKEN environment variable")
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
logger.info("Getting fresh token from Pipeboard (caching disabled)")
|
|
320
|
+
|
|
321
|
+
# If force refresh or no token/expired token, get a new one from Pipeboard
|
|
322
|
+
try:
|
|
323
|
+
# Make a request to get the token, using the same URL format as initiate_auth_flow
|
|
324
|
+
url = f"{PIPEBOARD_API_BASE}/meta/token?api_token={self.api_token}"
|
|
325
|
+
headers = {
|
|
326
|
+
"Content-Type": "application/json"
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
logger.info(f"Requesting token from {url}")
|
|
330
|
+
|
|
331
|
+
# Add timeout for better error messages
|
|
332
|
+
try:
|
|
333
|
+
response = requests.get(url, headers=headers, timeout=10)
|
|
334
|
+
except requests.exceptions.Timeout:
|
|
335
|
+
logger.error("TOKEN VALIDATION FAILED: Timeout while connecting to Pipeboard API")
|
|
336
|
+
logger.error(f"Could not connect to {PIPEBOARD_API_BASE} within 10 seconds")
|
|
337
|
+
return None
|
|
338
|
+
except requests.exceptions.ConnectionError:
|
|
339
|
+
logger.error("TOKEN VALIDATION FAILED: Connection error with Pipeboard API")
|
|
340
|
+
logger.error(f"Could not connect to {PIPEBOARD_API_BASE} - check if service is running")
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
logger.info(f"Token request response status: {response.status_code}")
|
|
344
|
+
|
|
345
|
+
# Better error handling with response content
|
|
346
|
+
if response.status_code != 200:
|
|
347
|
+
logger.error(f"TOKEN VALIDATION FAILED: HTTP error {response.status_code}")
|
|
348
|
+
error_text = response.text if response.text else "No response content"
|
|
349
|
+
logger.error(f"Response content: {error_text}")
|
|
350
|
+
|
|
351
|
+
# Add more specific error messages for common status codes
|
|
352
|
+
if response.status_code == 401:
|
|
353
|
+
logger.error("Authentication failed: Invalid Pipeboard API token")
|
|
354
|
+
elif response.status_code == 404:
|
|
355
|
+
logger.error("Endpoint not found: Check if Pipeboard API service is running correctly")
|
|
356
|
+
elif response.status_code == 400:
|
|
357
|
+
logger.error("Bad request: The request to Pipeboard API was malformed")
|
|
358
|
+
|
|
359
|
+
response.raise_for_status()
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
data = response.json()
|
|
363
|
+
logger.info(f"Received token response with keys: {', '.join(data.keys())}")
|
|
364
|
+
except json.JSONDecodeError:
|
|
365
|
+
logger.error("TOKEN VALIDATION FAILED: Invalid JSON response from Pipeboard API")
|
|
366
|
+
logger.error(f"Response content (first 100 chars): {response.text[:100]}")
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
# Validate response data
|
|
370
|
+
if "access_token" not in data:
|
|
371
|
+
logger.error("TOKEN VALIDATION FAILED: No access_token in Pipeboard API response")
|
|
372
|
+
logger.error(f"Response keys: {', '.join(data.keys())}")
|
|
373
|
+
if "error" in data:
|
|
374
|
+
logger.error(f"Error details: {data['error']}")
|
|
375
|
+
else:
|
|
376
|
+
logger.error("No error information available in response")
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
# Create new token info
|
|
380
|
+
self.token_info = TokenInfo(
|
|
381
|
+
access_token=data.get("access_token"),
|
|
382
|
+
expires_at=data.get("expires_at"),
|
|
383
|
+
token_type=data.get("token_type", "bearer")
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Note: Token caching is disabled
|
|
387
|
+
|
|
388
|
+
masked_token = self.token_info.access_token[:10] + "..." + self.token_info.access_token[-5:] if self.token_info.access_token else "None"
|
|
389
|
+
logger.info(f"Successfully retrieved access token: {masked_token}")
|
|
390
|
+
return self.token_info.access_token
|
|
391
|
+
except requests.RequestException as e:
|
|
392
|
+
status_code = e.response.status_code if hasattr(e, 'response') and e.response else None
|
|
393
|
+
response_text = e.response.text if hasattr(e, 'response') and e.response else "No response"
|
|
394
|
+
|
|
395
|
+
if status_code == 401:
|
|
396
|
+
logger.error(f"Unauthorized: Check your PIPEBOARD_API_TOKEN. Response: {response_text}")
|
|
397
|
+
elif status_code == 404:
|
|
398
|
+
logger.error(f"No token available: You might need to complete authorization first. Response: {response_text}")
|
|
399
|
+
# Return None so caller can handle the auth flow
|
|
400
|
+
return None
|
|
401
|
+
else:
|
|
402
|
+
logger.error(f"Error getting access token (status {status_code}): {e}")
|
|
403
|
+
logger.error(f"Response content: {response_text}")
|
|
404
|
+
return None
|
|
405
|
+
except Exception as e:
|
|
406
|
+
logger.error(f"Unexpected error getting access token: {e}")
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
def invalidate_token(self) -> None:
|
|
410
|
+
"""Invalidate the current token, usually because it has expired or is invalid"""
|
|
411
|
+
if self.token_info:
|
|
412
|
+
logger.info(f"Invalidating token: {self.token_info.access_token[:10]}...")
|
|
413
|
+
self.token_info = None
|
|
414
|
+
|
|
415
|
+
# Remove the cached token file
|
|
416
|
+
try:
|
|
417
|
+
cache_path = self._get_token_cache_path()
|
|
418
|
+
if cache_path.exists():
|
|
419
|
+
os.remove(cache_path)
|
|
420
|
+
logger.info(f"Removed cached token file: {cache_path}")
|
|
421
|
+
else:
|
|
422
|
+
logger.debug(f"No token cache file to remove: {cache_path}")
|
|
423
|
+
except Exception as e:
|
|
424
|
+
logger.error(f"Error removing cached token file: {e}")
|
|
425
|
+
else:
|
|
426
|
+
logger.debug("No token to invalidate")
|
|
427
|
+
|
|
428
|
+
def test_token_validity(self) -> bool:
|
|
429
|
+
"""
|
|
430
|
+
Test if the current token is valid with the Meta Graph API
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
True if valid, False otherwise
|
|
434
|
+
"""
|
|
435
|
+
if not self.token_info or not self.token_info.access_token:
|
|
436
|
+
logger.debug("No token to test")
|
|
437
|
+
logger.error("TOKEN VALIDATION FAILED: Missing token to test")
|
|
438
|
+
return False
|
|
439
|
+
|
|
440
|
+
# Log token details for debugging (partial token for security)
|
|
441
|
+
masked_token = self.token_info.access_token[:5] + "..." + self.token_info.access_token[-5:] if self.token_info.access_token else "None"
|
|
442
|
+
token_type = self.token_info.token_type if hasattr(self.token_info, 'token_type') and self.token_info.token_type else "bearer"
|
|
443
|
+
logger.debug(f"Testing token validity (token: {masked_token}, type: {token_type})")
|
|
444
|
+
|
|
445
|
+
try:
|
|
446
|
+
# Make a simple request to the /me endpoint to test the token
|
|
447
|
+
META_GRAPH_API_VERSION = "v24.0"
|
|
448
|
+
url = f"https://graph.facebook.com/{META_GRAPH_API_VERSION}/me"
|
|
449
|
+
headers = {"Authorization": f"Bearer {self.token_info.access_token}"}
|
|
450
|
+
|
|
451
|
+
logger.debug(f"Testing token validity with request to {url}")
|
|
452
|
+
|
|
453
|
+
# Add timeout and better error handling
|
|
454
|
+
try:
|
|
455
|
+
response = requests.get(url, headers=headers, timeout=10)
|
|
456
|
+
except requests.exceptions.Timeout:
|
|
457
|
+
logger.error("TOKEN VALIDATION FAILED: Timeout while connecting to Meta API")
|
|
458
|
+
logger.error("The Graph API did not respond within 10 seconds")
|
|
459
|
+
return False
|
|
460
|
+
except requests.exceptions.ConnectionError:
|
|
461
|
+
logger.error("TOKEN VALIDATION FAILED: Connection error with Meta API")
|
|
462
|
+
logger.error("Could not establish connection to Graph API - check network connectivity")
|
|
463
|
+
return False
|
|
464
|
+
|
|
465
|
+
if response.status_code == 200:
|
|
466
|
+
data = response.json()
|
|
467
|
+
logger.debug(f"Token is valid. User ID: {data.get('id')}")
|
|
468
|
+
# Add more useful user information for debugging
|
|
469
|
+
user_info = f"User ID: {data.get('id')}"
|
|
470
|
+
if 'name' in data:
|
|
471
|
+
user_info += f", Name: {data.get('name')}"
|
|
472
|
+
logger.info(f"Meta API token validated successfully ({user_info})")
|
|
473
|
+
return True
|
|
474
|
+
else:
|
|
475
|
+
logger.error(f"TOKEN VALIDATION FAILED: API returned status {response.status_code}")
|
|
476
|
+
|
|
477
|
+
# Try to parse the error response for more detailed information
|
|
478
|
+
try:
|
|
479
|
+
error_data = response.json()
|
|
480
|
+
if 'error' in error_data:
|
|
481
|
+
error_obj = error_data.get('error', {})
|
|
482
|
+
error_code = error_obj.get('code', 'unknown')
|
|
483
|
+
error_message = error_obj.get('message', 'Unknown error')
|
|
484
|
+
logger.error(f"Meta API error: Code {error_code} - {error_message}")
|
|
485
|
+
|
|
486
|
+
# Add specific guidance for common error codes
|
|
487
|
+
if error_code == 190:
|
|
488
|
+
logger.error("Error indicates the token is invalid or has expired")
|
|
489
|
+
elif error_code == 4:
|
|
490
|
+
logger.error("Error indicates rate limiting - too many requests")
|
|
491
|
+
elif error_code == 200:
|
|
492
|
+
logger.error("Error indicates API permissions or configuration issue")
|
|
493
|
+
else:
|
|
494
|
+
logger.error(f"No error object in response: {error_data}")
|
|
495
|
+
except json.JSONDecodeError:
|
|
496
|
+
logger.error(f"Could not parse error response: {response.text[:200]}")
|
|
497
|
+
|
|
498
|
+
return False
|
|
499
|
+
except Exception as e:
|
|
500
|
+
logger.error(f"TOKEN VALIDATION FAILED: Unexpected error: {str(e)}")
|
|
501
|
+
|
|
502
|
+
# Add stack trace for debugging complex issues
|
|
503
|
+
import traceback
|
|
504
|
+
logger.error(f"Stack trace: {traceback.format_exc()}")
|
|
505
|
+
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# Create singleton instance
|
|
510
|
+
pipeboard_auth_manager = PipeboardAuthManager()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Report generation functionality for Meta Ads API."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional, Dict, Any, List, Union
|
|
6
|
+
from .api import meta_api_tool, ensure_act_prefix
|
|
7
|
+
from .server import mcp_server
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Only register the generate_report function if the environment variable is set
|
|
11
|
+
ENABLE_REPORT_GENERATION = bool(os.environ.get("META_ADS_ENABLE_REPORTS", ""))
|
|
12
|
+
|
|
13
|
+
if ENABLE_REPORT_GENERATION:
|
|
14
|
+
@mcp_server.tool()
|
|
15
|
+
async def generate_report(
|
|
16
|
+
account_id: str,
|
|
17
|
+
access_token: Optional[str] = None,
|
|
18
|
+
report_type: str = "account",
|
|
19
|
+
time_range: str = "last_30d",
|
|
20
|
+
campaign_ids: Optional[List[str]] = None,
|
|
21
|
+
export_format: str = "pdf",
|
|
22
|
+
report_name: Optional[str] = None,
|
|
23
|
+
include_sections: Optional[List[str]] = None,
|
|
24
|
+
breakdowns: Optional[List[str]] = None,
|
|
25
|
+
comparison_period: Optional[str] = None
|
|
26
|
+
) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Generate comprehensive Meta Ads performance reports.
|
|
29
|
+
|
|
30
|
+
**This is a premium feature available with Pipeboard Pro.**
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
account_id: Meta Ads account ID (format: act_XXXXXXXXX)
|
|
34
|
+
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
35
|
+
report_type: Type of report to generate (account, campaign, comparison)
|
|
36
|
+
time_range: Time period for the report (e.g., 'last_30d', 'last_7d', 'this_month')
|
|
37
|
+
campaign_ids: Specific campaign IDs (required for campaign/comparison reports)
|
|
38
|
+
export_format: Output format for the report (pdf, json, html)
|
|
39
|
+
report_name: Custom name for the report (auto-generated if not provided)
|
|
40
|
+
include_sections: Specific sections to include in the report
|
|
41
|
+
breakdowns: Audience breakdown dimensions (age, gender, country, etc.)
|
|
42
|
+
comparison_period: Time period for comparison analysis
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
# Validate required parameters
|
|
46
|
+
if not account_id:
|
|
47
|
+
return json.dumps({
|
|
48
|
+
"error": "invalid_parameters",
|
|
49
|
+
"message": "Account ID is required",
|
|
50
|
+
"details": {
|
|
51
|
+
"required_parameter": "account_id",
|
|
52
|
+
"format": "act_XXXXXXXXX"
|
|
53
|
+
}
|
|
54
|
+
}, indent=2)
|
|
55
|
+
|
|
56
|
+
account_id = ensure_act_prefix(account_id)
|
|
57
|
+
|
|
58
|
+
# For campaign and comparison reports, campaign_ids are required
|
|
59
|
+
if report_type in ["campaign", "comparison"] and not campaign_ids:
|
|
60
|
+
return json.dumps({
|
|
61
|
+
"error": "invalid_parameters",
|
|
62
|
+
"message": f"Campaign IDs are required for {report_type} reports",
|
|
63
|
+
"details": {
|
|
64
|
+
"required_parameter": "campaign_ids",
|
|
65
|
+
"format": "Array of campaign ID strings"
|
|
66
|
+
}
|
|
67
|
+
}, indent=2)
|
|
68
|
+
|
|
69
|
+
# Return premium feature upgrade message
|
|
70
|
+
return json.dumps({
|
|
71
|
+
"error": "premium_feature_required",
|
|
72
|
+
"message": "Professional report generation is a premium feature",
|
|
73
|
+
"details": {
|
|
74
|
+
"feature": "Automated PDF Report Generation",
|
|
75
|
+
"description": "Create professional client-ready reports with performance insights, recommendations, and white-label branding",
|
|
76
|
+
"benefits": [
|
|
77
|
+
"Executive summary with key metrics",
|
|
78
|
+
"Performance breakdowns and trends",
|
|
79
|
+
"Audience insights and recommendations",
|
|
80
|
+
"Professional PDF formatting",
|
|
81
|
+
"White-label branding options",
|
|
82
|
+
"Campaign comparison analysis",
|
|
83
|
+
"Creative performance insights",
|
|
84
|
+
"Automated scheduling options"
|
|
85
|
+
],
|
|
86
|
+
"upgrade_url": "https://pipeboard.co/upgrade",
|
|
87
|
+
"contact_email": "info@pipeboard.co",
|
|
88
|
+
"early_access": "Contact us for early access and special pricing"
|
|
89
|
+
},
|
|
90
|
+
"request_parameters": {
|
|
91
|
+
"account_id": account_id,
|
|
92
|
+
"report_type": report_type,
|
|
93
|
+
"time_range": time_range,
|
|
94
|
+
"export_format": export_format,
|
|
95
|
+
"campaign_ids": campaign_ids or [],
|
|
96
|
+
"include_sections": include_sections or [],
|
|
97
|
+
"breakdowns": breakdowns or []
|
|
98
|
+
},
|
|
99
|
+
"preview": {
|
|
100
|
+
"available_data": {
|
|
101
|
+
"account_name": f"Account {account_id}",
|
|
102
|
+
"campaigns_count": len(campaign_ids) if campaign_ids else "All campaigns",
|
|
103
|
+
"time_range": time_range,
|
|
104
|
+
"estimated_report_pages": 8 if report_type == "account" else 6,
|
|
105
|
+
"report_format": export_format.upper()
|
|
106
|
+
},
|
|
107
|
+
"sample_metrics": {
|
|
108
|
+
"total_spend": "$12,450",
|
|
109
|
+
"total_impressions": "2.3M",
|
|
110
|
+
"total_clicks": "45.2K",
|
|
111
|
+
"average_cpc": "$0.85",
|
|
112
|
+
"average_cpm": "$15.20",
|
|
113
|
+
"click_through_rate": "1.96%",
|
|
114
|
+
"roas": "4.2x"
|
|
115
|
+
},
|
|
116
|
+
"available_sections": [
|
|
117
|
+
"executive_summary",
|
|
118
|
+
"performance_overview",
|
|
119
|
+
"campaign_breakdown",
|
|
120
|
+
"audience_insights",
|
|
121
|
+
"creative_performance",
|
|
122
|
+
"recommendations",
|
|
123
|
+
"appendix"
|
|
124
|
+
],
|
|
125
|
+
"supported_breakdowns": [
|
|
126
|
+
"age",
|
|
127
|
+
"gender",
|
|
128
|
+
"country",
|
|
129
|
+
"region",
|
|
130
|
+
"placement",
|
|
131
|
+
"device_platform",
|
|
132
|
+
"publisher_platform"
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
}, indent=2)
|