meta-ads-mcp 0.2.5__py3-none-any.whl → 0.2.8__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 +3 -1
- meta_ads_mcp/api.py +122 -53
- meta_ads_mcp/core/__init__.py +2 -1
- meta_ads_mcp/core/ads.py +61 -1
- meta_ads_mcp/core/adsets.py +8 -3
- meta_ads_mcp/core/api.py +29 -1
- meta_ads_mcp/core/auth.py +91 -1270
- meta_ads_mcp/core/authentication.py +103 -49
- meta_ads_mcp/core/callback_server.py +958 -0
- meta_ads_mcp/core/pipeboard_auth.py +484 -0
- meta_ads_mcp/core/server.py +49 -4
- meta_ads_mcp/core/utils.py +11 -5
- {meta_ads_mcp-0.2.5.dist-info → meta_ads_mcp-0.2.8.dist-info}/METADATA +139 -32
- meta_ads_mcp-0.2.8.dist-info/RECORD +21 -0
- meta_ads_mcp-0.2.5.dist-info/RECORD +0 -19
- {meta_ads_mcp-0.2.5.dist-info → meta_ads_mcp-0.2.8.dist-info}/WHEEL +0 -0
- {meta_ads_mcp-0.2.5.dist-info → meta_ads_mcp-0.2.8.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,484 @@
|
|
|
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
|
+
# Enable more detailed logging
|
|
13
|
+
import logging
|
|
14
|
+
logger.setLevel(logging.DEBUG)
|
|
15
|
+
|
|
16
|
+
# Base URL for pipeboard API
|
|
17
|
+
PIPEBOARD_API_BASE = "https://pipeboard.co/api"
|
|
18
|
+
|
|
19
|
+
# Debug message about API base URL
|
|
20
|
+
logger.info(f"Pipeboard API base URL: {PIPEBOARD_API_BASE}")
|
|
21
|
+
|
|
22
|
+
class TokenInfo:
|
|
23
|
+
"""Stores token information including expiration"""
|
|
24
|
+
def __init__(self, access_token: str, expires_at: str = None, token_type: str = None):
|
|
25
|
+
self.access_token = access_token
|
|
26
|
+
self.expires_at = expires_at
|
|
27
|
+
self.token_type = token_type
|
|
28
|
+
self.created_at = int(time.time())
|
|
29
|
+
logger.debug(f"TokenInfo created. Expires at: {expires_at if expires_at else 'Not specified'}")
|
|
30
|
+
|
|
31
|
+
def is_expired(self) -> bool:
|
|
32
|
+
"""Check if the token is expired"""
|
|
33
|
+
if not self.expires_at:
|
|
34
|
+
logger.debug("No expiration date set for token, assuming not expired")
|
|
35
|
+
return False # If no expiration is set, assume it's not expired
|
|
36
|
+
|
|
37
|
+
# Parse ISO 8601 date format to timestamp
|
|
38
|
+
try:
|
|
39
|
+
# Convert the expires_at string to a timestamp
|
|
40
|
+
# Format is like "2023-12-31T23:59:59.999Z" or "2023-12-31T23:59:59.999+00:00"
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
|
|
43
|
+
# Remove the Z suffix if present and handle +00:00 format
|
|
44
|
+
expires_at_str = self.expires_at
|
|
45
|
+
if expires_at_str.endswith('Z'):
|
|
46
|
+
expires_at_str = expires_at_str[:-1] # Remove Z
|
|
47
|
+
|
|
48
|
+
# Handle microseconds if present
|
|
49
|
+
if '.' in expires_at_str:
|
|
50
|
+
datetime_format = "%Y-%m-%dT%H:%M:%S.%f"
|
|
51
|
+
else:
|
|
52
|
+
datetime_format = "%Y-%m-%dT%H:%M:%S"
|
|
53
|
+
|
|
54
|
+
# Handle timezone offset
|
|
55
|
+
timezone_offset = "+00:00"
|
|
56
|
+
if "+" in expires_at_str:
|
|
57
|
+
expires_at_str, timezone_offset = expires_at_str.split("+")
|
|
58
|
+
timezone_offset = "+" + timezone_offset
|
|
59
|
+
|
|
60
|
+
# Parse the datetime without timezone info
|
|
61
|
+
expires_datetime = datetime.strptime(expires_at_str, datetime_format)
|
|
62
|
+
|
|
63
|
+
# Convert to timestamp (assume UTC)
|
|
64
|
+
expires_timestamp = expires_datetime.timestamp()
|
|
65
|
+
current_time = time.time()
|
|
66
|
+
|
|
67
|
+
# Check if token is expired and log result
|
|
68
|
+
is_expired = current_time > expires_timestamp
|
|
69
|
+
time_diff = expires_timestamp - current_time
|
|
70
|
+
if is_expired:
|
|
71
|
+
logger.debug(f"Token is expired! Current time: {datetime.fromtimestamp(current_time)}, "
|
|
72
|
+
f"Expires at: {datetime.fromtimestamp(expires_timestamp)}, "
|
|
73
|
+
f"Expired {abs(time_diff):.0f} seconds ago")
|
|
74
|
+
else:
|
|
75
|
+
logger.debug(f"Token is still valid. Expires at: {datetime.fromtimestamp(expires_timestamp)}, "
|
|
76
|
+
f"Time remaining: {time_diff:.0f} seconds")
|
|
77
|
+
|
|
78
|
+
return is_expired
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.error(f"Error parsing expiration date: {e}")
|
|
81
|
+
# Log the actual value to help diagnose format issues
|
|
82
|
+
logger.error(f"Invalid expires_at value: '{self.expires_at}'")
|
|
83
|
+
# Log detailed error information
|
|
84
|
+
import traceback
|
|
85
|
+
logger.error(f"Traceback: {traceback.format_exc()}")
|
|
86
|
+
return False # If we can't parse the date, assume it's not expired
|
|
87
|
+
|
|
88
|
+
def serialize(self) -> Dict[str, Any]:
|
|
89
|
+
"""Convert to a dictionary for storage"""
|
|
90
|
+
return {
|
|
91
|
+
"access_token": self.access_token,
|
|
92
|
+
"expires_at": self.expires_at,
|
|
93
|
+
"token_type": self.token_type,
|
|
94
|
+
"created_at": self.created_at
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def deserialize(cls, data: Dict[str, Any]) -> 'TokenInfo':
|
|
99
|
+
"""Create from a stored dictionary"""
|
|
100
|
+
logger.debug(f"Deserializing token data with keys: {', '.join(data.keys())}")
|
|
101
|
+
if 'expires_at' in data:
|
|
102
|
+
logger.debug(f"Token expires_at from cache: {data['expires_at']}")
|
|
103
|
+
|
|
104
|
+
token = cls(
|
|
105
|
+
access_token=data.get("access_token", ""),
|
|
106
|
+
expires_at=data.get("expires_at"),
|
|
107
|
+
token_type=data.get("token_type")
|
|
108
|
+
)
|
|
109
|
+
token.created_at = data.get("created_at", int(time.time()))
|
|
110
|
+
return token
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class PipeboardAuthManager:
|
|
114
|
+
"""Manages authentication with Meta APIs via pipeboard.co"""
|
|
115
|
+
def __init__(self):
|
|
116
|
+
self.api_token = os.environ.get("PIPEBOARD_API_TOKEN", "")
|
|
117
|
+
logger.debug(f"PipeboardAuthManager initialized with API token: {self.api_token[:5]}..." if self.api_token else "No API token")
|
|
118
|
+
if self.api_token:
|
|
119
|
+
logger.info("Pipeboard authentication enabled. Will use pipeboard.co for Meta authentication.")
|
|
120
|
+
else:
|
|
121
|
+
logger.info("Pipeboard authentication not enabled. Set PIPEBOARD_API_TOKEN environment variable to enable.")
|
|
122
|
+
self.token_info = None
|
|
123
|
+
self._load_cached_token()
|
|
124
|
+
|
|
125
|
+
def _get_token_cache_path(self) -> Path:
|
|
126
|
+
"""Get the platform-specific path for token cache file"""
|
|
127
|
+
if platform.system() == "Windows":
|
|
128
|
+
base_path = Path(os.environ.get("APPDATA", ""))
|
|
129
|
+
elif platform.system() == "Darwin": # macOS
|
|
130
|
+
base_path = Path.home() / "Library" / "Application Support"
|
|
131
|
+
else: # Assume Linux/Unix
|
|
132
|
+
base_path = Path.home() / ".config"
|
|
133
|
+
|
|
134
|
+
# Create directory if it doesn't exist
|
|
135
|
+
cache_dir = base_path / "meta-ads-mcp"
|
|
136
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
cache_path = cache_dir / "pipeboard_token_cache.json"
|
|
139
|
+
logger.debug(f"Token cache path: {cache_path}")
|
|
140
|
+
return cache_path
|
|
141
|
+
|
|
142
|
+
def _load_cached_token(self) -> bool:
|
|
143
|
+
"""Load token from cache if available"""
|
|
144
|
+
cache_path = self._get_token_cache_path()
|
|
145
|
+
|
|
146
|
+
if not cache_path.exists():
|
|
147
|
+
logger.debug(f"Token cache file not found at {cache_path}")
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
with open(cache_path, "r") as f:
|
|
152
|
+
logger.debug(f"Reading token cache from {cache_path}")
|
|
153
|
+
data = json.load(f)
|
|
154
|
+
self.token_info = TokenInfo.deserialize(data)
|
|
155
|
+
|
|
156
|
+
# Log token details (partial token for security)
|
|
157
|
+
masked_token = self.token_info.access_token[:10] + "..." + self.token_info.access_token[-5:] if self.token_info.access_token else "None"
|
|
158
|
+
logger.debug(f"Loaded token: {masked_token}")
|
|
159
|
+
|
|
160
|
+
# Check if token is expired
|
|
161
|
+
if self.token_info.is_expired():
|
|
162
|
+
logger.info("Cached token is expired")
|
|
163
|
+
self.token_info = None
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
logger.info(f"Loaded cached token (expires at {self.token_info.expires_at})")
|
|
167
|
+
return True
|
|
168
|
+
except json.JSONDecodeError as e:
|
|
169
|
+
logger.error(f"Error parsing token cache file: {e}")
|
|
170
|
+
logger.debug("Token cache file might be corrupted, trying to read raw content")
|
|
171
|
+
try:
|
|
172
|
+
with open(cache_path, "r") as f:
|
|
173
|
+
raw_content = f.read()
|
|
174
|
+
logger.debug(f"Raw cache file content (first 100 chars): {raw_content[:100]}")
|
|
175
|
+
except Exception as e2:
|
|
176
|
+
logger.error(f"Could not read raw cache file: {e2}")
|
|
177
|
+
return False
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"Error loading cached token: {e}")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def _save_token_to_cache(self) -> None:
|
|
183
|
+
"""Save token to cache file"""
|
|
184
|
+
if not self.token_info:
|
|
185
|
+
logger.debug("No token to save to cache")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
cache_path = self._get_token_cache_path()
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
token_data = self.token_info.serialize()
|
|
192
|
+
logger.debug(f"Saving token to cache. Expires at: {token_data.get('expires_at')}")
|
|
193
|
+
|
|
194
|
+
with open(cache_path, "w") as f:
|
|
195
|
+
json.dump(token_data, f)
|
|
196
|
+
logger.info(f"Token cached at: {cache_path}")
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error(f"Error saving token to cache: {e}")
|
|
199
|
+
|
|
200
|
+
def initiate_auth_flow(self) -> Dict[str, str]:
|
|
201
|
+
"""
|
|
202
|
+
Initiate the Meta OAuth flow via pipeboard.co
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Dict with loginUrl and status info
|
|
206
|
+
"""
|
|
207
|
+
if not self.api_token:
|
|
208
|
+
logger.error("No PIPEBOARD_API_TOKEN environment variable set")
|
|
209
|
+
raise ValueError("No PIPEBOARD_API_TOKEN environment variable set")
|
|
210
|
+
|
|
211
|
+
# Exactly match the format used in meta_auth_test.sh
|
|
212
|
+
url = f"{PIPEBOARD_API_BASE}/meta/auth?api_token={self.api_token}"
|
|
213
|
+
headers = {
|
|
214
|
+
"Content-Type": "application/json"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
logger.info(f"Initiating auth flow with POST request to {url}")
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
# Make the POST request exactly as in the working meta_auth_test.sh script
|
|
221
|
+
response = requests.post(url, headers=headers)
|
|
222
|
+
logger.info(f"Auth flow response status: {response.status_code}")
|
|
223
|
+
|
|
224
|
+
# Better error handling
|
|
225
|
+
if response.status_code != 200:
|
|
226
|
+
logger.error(f"Auth flow error: HTTP {response.status_code}")
|
|
227
|
+
error_text = response.text if response.text else "No response content"
|
|
228
|
+
logger.error(f"Response content: {error_text}")
|
|
229
|
+
if response.status_code == 404:
|
|
230
|
+
raise ValueError(f"Pipeboard API endpoint not found. Check if the server is running at {PIPEBOARD_API_BASE}")
|
|
231
|
+
elif response.status_code == 401:
|
|
232
|
+
raise ValueError(f"Unauthorized: Invalid API token. Check your PIPEBOARD_API_TOKEN.")
|
|
233
|
+
|
|
234
|
+
response.raise_for_status()
|
|
235
|
+
|
|
236
|
+
# Parse the response
|
|
237
|
+
try:
|
|
238
|
+
data = response.json()
|
|
239
|
+
logger.info(f"Received response keys: {', '.join(data.keys())}")
|
|
240
|
+
except json.JSONDecodeError:
|
|
241
|
+
logger.error(f"Could not parse JSON response: {response.text}")
|
|
242
|
+
raise ValueError(f"Invalid JSON response from auth endpoint: {response.text[:100]}")
|
|
243
|
+
|
|
244
|
+
# Log auth flow response (without sensitive information)
|
|
245
|
+
if 'loginUrl' in data:
|
|
246
|
+
logger.info(f"Auth flow initiated successfully with login URL: {data['loginUrl'][:30]}...")
|
|
247
|
+
else:
|
|
248
|
+
logger.warning(f"Auth flow response missing loginUrl field. Response keys: {', '.join(data.keys())}")
|
|
249
|
+
|
|
250
|
+
return data
|
|
251
|
+
except requests.exceptions.ConnectionError as e:
|
|
252
|
+
logger.error(f"Connection error to Pipeboard: {e}")
|
|
253
|
+
logger.debug(f"Attempting to connect to: {PIPEBOARD_API_BASE}")
|
|
254
|
+
raise
|
|
255
|
+
except requests.exceptions.Timeout as e:
|
|
256
|
+
logger.error(f"Timeout connecting to Pipeboard: {e}")
|
|
257
|
+
raise
|
|
258
|
+
except requests.exceptions.RequestException as e:
|
|
259
|
+
logger.error(f"Error initiating auth flow: {e}")
|
|
260
|
+
raise
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(f"Unexpected error initiating auth flow: {e}")
|
|
263
|
+
raise
|
|
264
|
+
|
|
265
|
+
def get_access_token(self, force_refresh: bool = False) -> Optional[str]:
|
|
266
|
+
"""
|
|
267
|
+
Get the current access token, refreshing if necessary or if forced
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
force_refresh: Force token refresh even if cached token exists
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Access token if available, None otherwise
|
|
274
|
+
"""
|
|
275
|
+
# First check if API token is configured
|
|
276
|
+
if not self.api_token:
|
|
277
|
+
logger.error("TOKEN VALIDATION FAILED: No Pipeboard API token configured")
|
|
278
|
+
logger.error("Please set PIPEBOARD_API_TOKEN environment variable")
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
# Check if we already have a valid token
|
|
282
|
+
if not force_refresh and self.token_info and not self.token_info.is_expired():
|
|
283
|
+
logger.debug("Using existing valid token")
|
|
284
|
+
return self.token_info.access_token
|
|
285
|
+
|
|
286
|
+
# If we have a token but it's expired, log that information
|
|
287
|
+
if not force_refresh and self.token_info and self.token_info.is_expired():
|
|
288
|
+
logger.error("TOKEN VALIDATION FAILED: Existing token is expired")
|
|
289
|
+
if self.token_info.expires_at:
|
|
290
|
+
logger.error(f"Token expiration time: {self.token_info.expires_at}")
|
|
291
|
+
|
|
292
|
+
logger.info(f"Getting new token (force_refresh={force_refresh})")
|
|
293
|
+
|
|
294
|
+
# If force refresh or no token/expired token, get a new one from Pipeboard
|
|
295
|
+
try:
|
|
296
|
+
# Make a request to get the token, using the same URL format as initiate_auth_flow
|
|
297
|
+
url = f"{PIPEBOARD_API_BASE}/meta/token?api_token={self.api_token}"
|
|
298
|
+
headers = {
|
|
299
|
+
"Content-Type": "application/json"
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
logger.info(f"Requesting token from {url}")
|
|
303
|
+
|
|
304
|
+
# Add timeout for better error messages
|
|
305
|
+
try:
|
|
306
|
+
response = requests.get(url, headers=headers, timeout=10)
|
|
307
|
+
except requests.exceptions.Timeout:
|
|
308
|
+
logger.error("TOKEN VALIDATION FAILED: Timeout while connecting to Pipeboard API")
|
|
309
|
+
logger.error(f"Could not connect to {PIPEBOARD_API_BASE} within 10 seconds")
|
|
310
|
+
return None
|
|
311
|
+
except requests.exceptions.ConnectionError:
|
|
312
|
+
logger.error("TOKEN VALIDATION FAILED: Connection error with Pipeboard API")
|
|
313
|
+
logger.error(f"Could not connect to {PIPEBOARD_API_BASE} - check if service is running")
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
logger.info(f"Token request response status: {response.status_code}")
|
|
317
|
+
|
|
318
|
+
# Better error handling with response content
|
|
319
|
+
if response.status_code != 200:
|
|
320
|
+
logger.error(f"TOKEN VALIDATION FAILED: HTTP error {response.status_code}")
|
|
321
|
+
error_text = response.text if response.text else "No response content"
|
|
322
|
+
logger.error(f"Response content: {error_text}")
|
|
323
|
+
|
|
324
|
+
# Add more specific error messages for common status codes
|
|
325
|
+
if response.status_code == 401:
|
|
326
|
+
logger.error("Authentication failed: Invalid Pipeboard API token")
|
|
327
|
+
elif response.status_code == 404:
|
|
328
|
+
logger.error("Endpoint not found: Check if Pipeboard API service is running correctly")
|
|
329
|
+
elif response.status_code == 400:
|
|
330
|
+
logger.error("Bad request: The request to Pipeboard API was malformed")
|
|
331
|
+
|
|
332
|
+
response.raise_for_status()
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
data = response.json()
|
|
336
|
+
logger.info(f"Received token response with keys: {', '.join(data.keys())}")
|
|
337
|
+
except json.JSONDecodeError:
|
|
338
|
+
logger.error("TOKEN VALIDATION FAILED: Invalid JSON response from Pipeboard API")
|
|
339
|
+
logger.error(f"Response content (first 100 chars): {response.text[:100]}")
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
# Validate response data
|
|
343
|
+
if "access_token" not in data:
|
|
344
|
+
logger.error("TOKEN VALIDATION FAILED: No access_token in Pipeboard API response")
|
|
345
|
+
logger.error(f"Response keys: {', '.join(data.keys())}")
|
|
346
|
+
if "error" in data:
|
|
347
|
+
logger.error(f"Error details: {data['error']}")
|
|
348
|
+
else:
|
|
349
|
+
logger.error("No error information available in response")
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
# Create new token info
|
|
353
|
+
self.token_info = TokenInfo(
|
|
354
|
+
access_token=data.get("access_token"),
|
|
355
|
+
expires_at=data.get("expires_at"),
|
|
356
|
+
token_type=data.get("token_type", "bearer")
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Save to cache
|
|
360
|
+
self._save_token_to_cache()
|
|
361
|
+
|
|
362
|
+
masked_token = self.token_info.access_token[:10] + "..." + self.token_info.access_token[-5:] if self.token_info.access_token else "None"
|
|
363
|
+
logger.info(f"Successfully retrieved access token: {masked_token}")
|
|
364
|
+
return self.token_info.access_token
|
|
365
|
+
except requests.RequestException as e:
|
|
366
|
+
status_code = e.response.status_code if hasattr(e, 'response') and e.response else None
|
|
367
|
+
response_text = e.response.text if hasattr(e, 'response') and e.response else "No response"
|
|
368
|
+
|
|
369
|
+
if status_code == 401:
|
|
370
|
+
logger.error(f"Unauthorized: Check your PIPEBOARD_API_TOKEN. Response: {response_text}")
|
|
371
|
+
elif status_code == 404:
|
|
372
|
+
logger.error(f"No token available: You might need to complete authorization first. Response: {response_text}")
|
|
373
|
+
# Return None so caller can handle the auth flow
|
|
374
|
+
return None
|
|
375
|
+
else:
|
|
376
|
+
logger.error(f"Error getting access token (status {status_code}): {e}")
|
|
377
|
+
logger.error(f"Response content: {response_text}")
|
|
378
|
+
return None
|
|
379
|
+
except Exception as e:
|
|
380
|
+
logger.error(f"Unexpected error getting access token: {e}")
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
def invalidate_token(self) -> None:
|
|
384
|
+
"""Invalidate the current token, usually because it has expired or is invalid"""
|
|
385
|
+
if self.token_info:
|
|
386
|
+
logger.info(f"Invalidating token: {self.token_info.access_token[:10]}...")
|
|
387
|
+
self.token_info = None
|
|
388
|
+
|
|
389
|
+
# Remove the cached token file
|
|
390
|
+
try:
|
|
391
|
+
cache_path = self._get_token_cache_path()
|
|
392
|
+
if cache_path.exists():
|
|
393
|
+
os.remove(cache_path)
|
|
394
|
+
logger.info(f"Removed cached token file: {cache_path}")
|
|
395
|
+
else:
|
|
396
|
+
logger.debug(f"No token cache file to remove: {cache_path}")
|
|
397
|
+
except Exception as e:
|
|
398
|
+
logger.error(f"Error removing cached token file: {e}")
|
|
399
|
+
else:
|
|
400
|
+
logger.debug("No token to invalidate")
|
|
401
|
+
|
|
402
|
+
def test_token_validity(self) -> bool:
|
|
403
|
+
"""
|
|
404
|
+
Test if the current token is valid with the Meta Graph API
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
True if valid, False otherwise
|
|
408
|
+
"""
|
|
409
|
+
if not self.token_info or not self.token_info.access_token:
|
|
410
|
+
logger.debug("No token to test")
|
|
411
|
+
logger.error("TOKEN VALIDATION FAILED: Missing token to test")
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
# Log token details for debugging (partial token for security)
|
|
415
|
+
masked_token = self.token_info.access_token[:5] + "..." + self.token_info.access_token[-5:] if self.token_info.access_token else "None"
|
|
416
|
+
token_type = self.token_info.token_type if hasattr(self.token_info, 'token_type') and self.token_info.token_type else "bearer"
|
|
417
|
+
logger.debug(f"Testing token validity (token: {masked_token}, type: {token_type})")
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
# Make a simple request to the /me endpoint to test the token
|
|
421
|
+
META_GRAPH_API_VERSION = "v20.0"
|
|
422
|
+
url = f"https://graph.facebook.com/{META_GRAPH_API_VERSION}/me"
|
|
423
|
+
headers = {"Authorization": f"Bearer {self.token_info.access_token}"}
|
|
424
|
+
|
|
425
|
+
logger.debug(f"Testing token validity with request to {url}")
|
|
426
|
+
|
|
427
|
+
# Add timeout and better error handling
|
|
428
|
+
try:
|
|
429
|
+
response = requests.get(url, headers=headers, timeout=10)
|
|
430
|
+
except requests.exceptions.Timeout:
|
|
431
|
+
logger.error("TOKEN VALIDATION FAILED: Timeout while connecting to Meta API")
|
|
432
|
+
logger.error("The Graph API did not respond within 10 seconds")
|
|
433
|
+
return False
|
|
434
|
+
except requests.exceptions.ConnectionError:
|
|
435
|
+
logger.error("TOKEN VALIDATION FAILED: Connection error with Meta API")
|
|
436
|
+
logger.error("Could not establish connection to Graph API - check network connectivity")
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
if response.status_code == 200:
|
|
440
|
+
data = response.json()
|
|
441
|
+
logger.debug(f"Token is valid. User ID: {data.get('id')}")
|
|
442
|
+
# Add more useful user information for debugging
|
|
443
|
+
user_info = f"User ID: {data.get('id')}"
|
|
444
|
+
if 'name' in data:
|
|
445
|
+
user_info += f", Name: {data.get('name')}"
|
|
446
|
+
logger.info(f"Meta API token validated successfully ({user_info})")
|
|
447
|
+
return True
|
|
448
|
+
else:
|
|
449
|
+
logger.error(f"TOKEN VALIDATION FAILED: API returned status {response.status_code}")
|
|
450
|
+
|
|
451
|
+
# Try to parse the error response for more detailed information
|
|
452
|
+
try:
|
|
453
|
+
error_data = response.json()
|
|
454
|
+
if 'error' in error_data:
|
|
455
|
+
error_obj = error_data.get('error', {})
|
|
456
|
+
error_code = error_obj.get('code', 'unknown')
|
|
457
|
+
error_message = error_obj.get('message', 'Unknown error')
|
|
458
|
+
logger.error(f"Meta API error: Code {error_code} - {error_message}")
|
|
459
|
+
|
|
460
|
+
# Add specific guidance for common error codes
|
|
461
|
+
if error_code == 190:
|
|
462
|
+
logger.error("Error indicates the token is invalid or has expired")
|
|
463
|
+
elif error_code == 4:
|
|
464
|
+
logger.error("Error indicates rate limiting - too many requests")
|
|
465
|
+
elif error_code == 200:
|
|
466
|
+
logger.error("Error indicates API permissions or configuration issue")
|
|
467
|
+
else:
|
|
468
|
+
logger.error(f"No error object in response: {error_data}")
|
|
469
|
+
except json.JSONDecodeError:
|
|
470
|
+
logger.error(f"Could not parse error response: {response.text[:200]}")
|
|
471
|
+
|
|
472
|
+
return False
|
|
473
|
+
except Exception as e:
|
|
474
|
+
logger.error(f"TOKEN VALIDATION FAILED: Unexpected error: {str(e)}")
|
|
475
|
+
|
|
476
|
+
# Add stack trace for debugging complex issues
|
|
477
|
+
import traceback
|
|
478
|
+
logger.error(f"Stack trace: {traceback.format_exc()}")
|
|
479
|
+
|
|
480
|
+
return False
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# Create singleton instance
|
|
484
|
+
pipeboard_auth_manager = PipeboardAuthManager()
|
meta_ads_mcp/core/server.py
CHANGED
|
@@ -4,9 +4,12 @@ from mcp.server.fastmcp import FastMCP
|
|
|
4
4
|
import argparse
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
|
+
import webbrowser
|
|
7
8
|
from .auth import login as login_auth
|
|
8
9
|
from .resources import list_resources, get_resource
|
|
9
10
|
from .utils import logger
|
|
11
|
+
from .pipeboard_auth import pipeboard_auth_manager
|
|
12
|
+
import time
|
|
10
13
|
|
|
11
14
|
# Initialize FastMCP server
|
|
12
15
|
mcp_server = FastMCP("meta-ads", use_consistent_tool_format=True)
|
|
@@ -78,7 +81,49 @@ def main():
|
|
|
78
81
|
# Handle login command
|
|
79
82
|
if args.login:
|
|
80
83
|
login_cli()
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
# Check for Pipeboard authentication and token
|
|
87
|
+
pipeboard_api_token = os.environ.get("PIPEBOARD_API_TOKEN")
|
|
88
|
+
if pipeboard_api_token:
|
|
89
|
+
logger.info("Using Pipeboard authentication")
|
|
90
|
+
# Check for existing token
|
|
91
|
+
token = pipeboard_auth_manager.get_access_token()
|
|
92
|
+
if not token:
|
|
93
|
+
logger.info("No valid Pipeboard token found. Initiating browser-based authentication flow.")
|
|
94
|
+
print("No valid Meta token found. Opening browser for authentication...")
|
|
95
|
+
try:
|
|
96
|
+
# Initialize the auth flow and get the login URL
|
|
97
|
+
auth_data = pipeboard_auth_manager.initiate_auth_flow()
|
|
98
|
+
login_url = auth_data.get('loginUrl')
|
|
99
|
+
if login_url:
|
|
100
|
+
logger.info(f"Opening browser with login URL: {login_url}")
|
|
101
|
+
webbrowser.open(login_url)
|
|
102
|
+
print("Please authorize the application in your browser.")
|
|
103
|
+
print("After authorization, the token will be automatically retrieved.")
|
|
104
|
+
print("Waiting for authentication to complete...")
|
|
105
|
+
|
|
106
|
+
# Poll for token completion
|
|
107
|
+
max_attempts = 30 # Try for 30 * 2 = 60 seconds
|
|
108
|
+
for attempt in range(max_attempts):
|
|
109
|
+
print(f"Waiting for authentication... ({attempt+1}/{max_attempts})")
|
|
110
|
+
# Try to get the token again
|
|
111
|
+
token = pipeboard_auth_manager.get_access_token(force_refresh=True)
|
|
112
|
+
if token:
|
|
113
|
+
print("Authentication successful!")
|
|
114
|
+
break
|
|
115
|
+
time.sleep(2) # Wait 2 seconds between attempts
|
|
116
|
+
|
|
117
|
+
if not token:
|
|
118
|
+
print("Authentication timed out. Starting server anyway.")
|
|
119
|
+
print("You may need to restart the server after completing authentication.")
|
|
120
|
+
else:
|
|
121
|
+
logger.error("No login URL received from Pipeboard API")
|
|
122
|
+
print("Error: Could not get authentication URL. Check your API token.")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.error(f"Error initiating browser-based authentication: {e}")
|
|
125
|
+
print(f"Error: Could not start authentication: {e}")
|
|
126
|
+
|
|
127
|
+
# Initialize and run the server
|
|
128
|
+
logger.info("Starting MCP server with stdio transport")
|
|
129
|
+
mcp_server.run(transport='stdio')
|
meta_ads_mcp/core/utils.py
CHANGED
|
@@ -17,11 +17,16 @@ import platform
|
|
|
17
17
|
META_APP_ID = os.environ.get("META_APP_ID", "")
|
|
18
18
|
META_APP_SECRET = os.environ.get("META_APP_SECRET", "")
|
|
19
19
|
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
# Only show warnings about Meta credentials if we're not using Pipeboard
|
|
21
|
+
# Check for Pipeboard token in environment
|
|
22
|
+
using_pipeboard = bool(os.environ.get("PIPEBOARD_API_TOKEN", ""))
|
|
23
|
+
|
|
24
|
+
# Print warning if Meta app credentials are not configured and not using Pipeboard
|
|
25
|
+
if not using_pipeboard:
|
|
26
|
+
if not META_APP_ID:
|
|
27
|
+
print("WARNING: META_APP_ID environment variable is not set. Authentication will not work properly.")
|
|
28
|
+
if not META_APP_SECRET:
|
|
29
|
+
print("WARNING: META_APP_SECRET environment variable is not set. Long-lived token exchange will not work.")
|
|
25
30
|
|
|
26
31
|
# Configure logging to file
|
|
27
32
|
def setup_logging():
|
|
@@ -55,6 +60,7 @@ def setup_logging():
|
|
|
55
60
|
# Log startup information
|
|
56
61
|
logger.info(f"Logging initialized. Log file: {log_file}")
|
|
57
62
|
logger.info(f"Platform: {platform.system()} {platform.release()}")
|
|
63
|
+
logger.info(f"Using Pipeboard authentication: {using_pipeboard}")
|
|
58
64
|
|
|
59
65
|
return logger
|
|
60
66
|
|