meta-ads-mcp 0.3.7__py3-none-any.whl → 0.3.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/api.py DELETED
@@ -1,2091 +0,0 @@
1
- from typing import Any, Dict, List, Optional
2
- import httpx
3
- import json
4
- import os
5
- import base64
6
- from mcp.server.fastmcp import FastMCP, Image
7
- import datetime
8
- from urllib.parse import urlparse, parse_qs
9
- from PIL import Image as PILImage
10
- import io
11
- import webbrowser
12
- import time
13
- import platform
14
- import pathlib
15
- import argparse
16
- import asyncio
17
- import threading
18
- import socket
19
- from http.server import HTTPServer, BaseHTTPRequestHandler
20
-
21
- # Initialize FastMCP server
22
- mcp_server = FastMCP("meta-ads-generated", use_consistent_tool_format=True)
23
-
24
- # Constants
25
- META_GRAPH_API_VERSION = "v22.0"
26
- META_GRAPH_API_BASE = f"https://graph.facebook.com/{META_GRAPH_API_VERSION}"
27
- USER_AGENT = "meta-ads-mcp/1.0"
28
-
29
- # Meta App configuration
30
- META_APP_ID = os.environ.get("META_APP_ID", "") # Default to empty string
31
-
32
- # Auth constants
33
- AUTH_SCOPE = "ads_management,ads_read,business_management,public_profile"
34
- AUTH_REDIRECT_URI = "http://localhost:8888/callback"
35
- AUTH_RESPONSE_TYPE = "token"
36
-
37
- # Global store for ad creative images
38
- ad_creative_images = {}
39
-
40
- # Global flag for authentication state
41
- needs_authentication = False
42
-
43
- # Global variable for server thread and state
44
- callback_server_thread = None
45
- callback_server_lock = threading.Lock()
46
- callback_server_running = False
47
- callback_server_port = None
48
- # Global token container for communication between threads
49
- token_container = {"token": None, "expires_in": None, "user_id": None}
50
-
51
- # Add these at the top of the file with the other global variables
52
- callback_server_instance = None
53
- server_shutdown_timer = None
54
-
55
- # Add this near other constants in the file
56
- CALLBACK_SERVER_TIMEOUT = 180 # 3 minutes timeout
57
-
58
- # Configuration class to store app ID and other config
59
- class MetaConfig:
60
- _instance = None
61
-
62
- def __new__(cls):
63
- if cls._instance is None:
64
- cls._instance = super(MetaConfig, cls).__new__(cls)
65
- cls._instance.app_id = os.environ.get("META_APP_ID", "") # Default from env
66
- cls._instance.initialized = False
67
- return cls._instance
68
-
69
- def set_app_id(self, app_id):
70
- """Set app ID from CLI or other source"""
71
- if app_id:
72
- print(f"Setting Meta App ID: {app_id}")
73
- self.app_id = app_id
74
- self.initialized = True
75
-
76
- def get_app_id(self):
77
- """Get current app ID"""
78
- return self.app_id
79
-
80
- def is_configured(self):
81
- """Check if app has been configured with valid app ID"""
82
- return bool(self.app_id)
83
-
84
- # Create global config instance
85
- meta_config = MetaConfig()
86
-
87
- # Callback Handler class definition
88
- class CallbackHandler(BaseHTTPRequestHandler):
89
- def do_GET(self):
90
- global token_container, auth_manager, needs_authentication
91
-
92
- if self.path.startswith("/callback"):
93
- # Return a page that extracts token from URL hash fragment
94
- self.send_response(200)
95
- self.send_header("Content-type", "text/html")
96
- self.end_headers()
97
-
98
- html = """
99
- <html>
100
- <head><title>Authentication Successful</title></head>
101
- <body>
102
- <h1>Authentication Successful!</h1>
103
- <p>You can close this window and return to the application.</p>
104
- <script>
105
- // Extract token from URL hash
106
- const hash = window.location.hash.substring(1);
107
- const params = new URLSearchParams(hash);
108
- const token = params.get('access_token');
109
- const expires_in = params.get('expires_in');
110
-
111
- // Send token back to server using fetch
112
- fetch('/token?' + new URLSearchParams({
113
- token: token,
114
- expires_in: expires_in
115
- }))
116
- .then(response => console.log('Token sent to app'));
117
- </script>
118
- </body>
119
- </html>
120
- """
121
- self.wfile.write(html.encode())
122
- return
123
-
124
- if self.path.startswith("/token"):
125
- # Extract token from query params
126
- query = parse_qs(urlparse(self.path).query)
127
- token_container["token"] = query.get("token", [""])[0]
128
-
129
- if "expires_in" in query:
130
- try:
131
- token_container["expires_in"] = int(query.get("expires_in", ["0"])[0])
132
- except ValueError:
133
- token_container["expires_in"] = None
134
-
135
- # Send success response
136
- self.send_response(200)
137
- self.send_header("Content-type", "text/plain")
138
- self.end_headers()
139
- self.wfile.write(b"Token received")
140
-
141
- # Process the token (save it) immediately
142
- if token_container["token"]:
143
- # Create token info and save to cache
144
- token_info = TokenInfo(
145
- access_token=token_container["token"],
146
- expires_in=token_container["expires_in"]
147
- )
148
- auth_manager.token_info = token_info
149
- auth_manager._save_token_to_cache()
150
-
151
- # Reset auth needed flag
152
- needs_authentication = False
153
-
154
- print(f"Token received and cached (expires in {token_container['expires_in']} seconds)")
155
- return
156
-
157
- # Silence server logs
158
- def log_message(self, format, *args):
159
- return
160
-
161
- # Authentication related classes
162
- class TokenInfo:
163
- """Stores token information including expiration"""
164
- def __init__(self, access_token: str, expires_in: int = None, user_id: str = None):
165
- self.access_token = access_token
166
- self.expires_in = expires_in
167
- self.user_id = user_id
168
- self.created_at = int(time.time())
169
-
170
- def is_expired(self) -> bool:
171
- """Check if the token is expired"""
172
- if not self.expires_in:
173
- return False # If no expiration is set, assume it's not expired
174
-
175
- current_time = int(time.time())
176
- return current_time > (self.created_at + self.expires_in)
177
-
178
- def serialize(self) -> Dict[str, Any]:
179
- """Convert to a dictionary for storage"""
180
- return {
181
- "access_token": self.access_token,
182
- "expires_in": self.expires_in,
183
- "user_id": self.user_id,
184
- "created_at": self.created_at
185
- }
186
-
187
- @classmethod
188
- def deserialize(cls, data: Dict[str, Any]) -> 'TokenInfo':
189
- """Create from a stored dictionary"""
190
- token = cls(
191
- access_token=data.get("access_token", ""),
192
- expires_in=data.get("expires_in"),
193
- user_id=data.get("user_id")
194
- )
195
- token.created_at = data.get("created_at", int(time.time()))
196
- return token
197
-
198
-
199
- class AuthManager:
200
- """Manages authentication with Meta APIs"""
201
- def __init__(self, app_id: str, redirect_uri: str = AUTH_REDIRECT_URI):
202
- self.app_id = app_id
203
- self.redirect_uri = redirect_uri
204
- self.token_info = None
205
- self._load_cached_token()
206
-
207
- def _get_token_cache_path(self) -> pathlib.Path:
208
- """Get the platform-specific path for token cache file"""
209
- if platform.system() == "Windows":
210
- base_path = pathlib.Path(os.environ.get("APPDATA", ""))
211
- elif platform.system() == "Darwin": # macOS
212
- base_path = pathlib.Path.home() / "Library" / "Application Support"
213
- else: # Assume Linux/Unix
214
- base_path = pathlib.Path.home() / ".config"
215
-
216
- # Create directory if it doesn't exist
217
- cache_dir = base_path / "meta-ads-mcp"
218
- cache_dir.mkdir(parents=True, exist_ok=True)
219
-
220
- return cache_dir / "token_cache.json"
221
-
222
- def _load_cached_token(self) -> bool:
223
- """Load token from cache if available"""
224
- cache_path = self._get_token_cache_path()
225
-
226
- if not cache_path.exists():
227
- return False
228
-
229
- try:
230
- with open(cache_path, "r") as f:
231
- data = json.load(f)
232
- self.token_info = TokenInfo.deserialize(data)
233
-
234
- # Check if token is expired
235
- if self.token_info.is_expired():
236
- print("Cached token is expired")
237
- self.token_info = None
238
- return False
239
-
240
- print(f"Loaded cached token (expires in {(self.token_info.created_at + self.token_info.expires_in) - int(time.time())} seconds)")
241
- return True
242
- except Exception as e:
243
- print(f"Error loading cached token: {e}")
244
- return False
245
-
246
- def _save_token_to_cache(self) -> None:
247
- """Save token to cache file"""
248
- if not self.token_info:
249
- return
250
-
251
- cache_path = self._get_token_cache_path()
252
-
253
- try:
254
- with open(cache_path, "w") as f:
255
- json.dump(self.token_info.serialize(), f)
256
- print(f"Token cached at: {cache_path}")
257
- except Exception as e:
258
- print(f"Error saving token to cache: {e}")
259
-
260
- def get_auth_url(self) -> str:
261
- """Generate the Facebook OAuth URL for desktop app flow"""
262
- return (
263
- f"https://www.facebook.com/v22.0/dialog/oauth?"
264
- f"client_id={self.app_id}&"
265
- f"redirect_uri={self.redirect_uri}&"
266
- f"scope={AUTH_SCOPE}&"
267
- f"response_type={AUTH_RESPONSE_TYPE}"
268
- )
269
-
270
- def authenticate(self, force_refresh: bool = False) -> Optional[str]:
271
- """
272
- Authenticate with Meta APIs
273
-
274
- Args:
275
- force_refresh: Force token refresh even if cached token exists
276
-
277
- Returns:
278
- Access token if successful, None otherwise
279
- """
280
- # Check if we already have a valid token
281
- if not force_refresh and self.token_info and not self.token_info.is_expired():
282
- return self.token_info.access_token
283
-
284
- # Start the callback server if not already running
285
- port = start_callback_server()
286
-
287
- # Generate the auth URL
288
- auth_url = self.get_auth_url()
289
-
290
- # Open browser with auth URL
291
- print(f"Opening browser with URL: {auth_url}")
292
- webbrowser.open(auth_url)
293
-
294
- # We don't wait for the token here anymore
295
- # The token will be processed by the callback server
296
- # Just return None to indicate we've started the flow
297
- return None
298
-
299
- def get_access_token(self) -> Optional[str]:
300
- """
301
- Get the current access token, refreshing if necessary
302
-
303
- Returns:
304
- Access token if available, None otherwise
305
- """
306
- if not self.token_info or self.token_info.is_expired():
307
- return None
308
-
309
- return self.token_info.access_token
310
-
311
- def invalidate_token(self) -> None:
312
- """Invalidate the current token, usually because it has expired or is invalid"""
313
- if self.token_info:
314
- print(f"Invalidating token: {self.token_info.access_token[:10]}...")
315
- self.token_info = None
316
-
317
- # Signal that authentication is needed
318
- global needs_authentication
319
- needs_authentication = True
320
-
321
- # Remove the cached token file
322
- try:
323
- cache_path = self._get_token_cache_path()
324
- if cache_path.exists():
325
- os.remove(cache_path)
326
- print(f"Removed cached token file: {cache_path}")
327
- except Exception as e:
328
- print(f"Error removing cached token: {e}")
329
-
330
- def clear_token(self) -> None:
331
- """Clear the current token and remove from cache"""
332
- self.invalidate_token()
333
-
334
- # Initialize auth manager with app_id from config
335
- auth_manager = AuthManager(meta_config.get_app_id())
336
-
337
- # Function to get token without requiring it as a parameter
338
- async def get_current_access_token() -> Optional[str]:
339
- """
340
- Get the current access token from cache
341
-
342
- Returns:
343
- Current access token or None if not available
344
- """
345
- return auth_manager.get_access_token()
346
-
347
- # Function to get current app ID from all possible sources
348
- def get_current_app_id():
349
- """Get the current app ID from MetaConfig."""
350
- # First try to get from our singleton config
351
- app_id = meta_config.get_app_id()
352
- if app_id:
353
- return app_id
354
-
355
- # If not in config yet, check environment as fallback
356
- env_app_id = os.environ.get("META_APP_ID", "")
357
- if env_app_id:
358
- # Update config for future use
359
- meta_config.set_app_id(env_app_id)
360
- return env_app_id
361
-
362
- # Last resort, return empty string
363
- return ""
364
-
365
- class GraphAPIError(Exception):
366
- """Exception raised for errors from the Graph API."""
367
- def __init__(self, error_data: Dict[str, Any]):
368
- self.error_data = error_data
369
- self.message = error_data.get('message', 'Unknown Graph API error')
370
- super().__init__(self.message)
371
-
372
- # Check if this is an auth error
373
- if "code" in error_data and error_data["code"] in [190, 102, 4]:
374
- # Common auth error codes
375
- auth_manager.invalidate_token()
376
-
377
- async def make_api_request(
378
- endpoint: str,
379
- access_token: str,
380
- params: Optional[Dict[str, Any]] = None,
381
- method: str = "GET"
382
- ) -> Dict[str, Any]:
383
- """
384
- Make a request to the Meta Graph API.
385
-
386
- Args:
387
- endpoint: API endpoint path (without base URL)
388
- access_token: Meta API access token
389
- params: Additional query parameters
390
- method: HTTP method (GET, POST, DELETE)
391
-
392
- Returns:
393
- API response as a dictionary
394
- """
395
- # Validate access token before proceeding
396
- if not access_token:
397
- print("API request attempted with blank access token")
398
- return {
399
- "error": "Authentication Required",
400
- "details": {
401
- "message": "A valid access token is required to access the Meta API",
402
- "action_required": "Please authenticate first"
403
- }
404
- }
405
-
406
- url = f"{META_GRAPH_API_BASE}/{endpoint}"
407
-
408
- headers = {
409
- "User-Agent": USER_AGENT,
410
- }
411
-
412
- request_params = params or {}
413
- request_params["access_token"] = access_token
414
-
415
- # Print the request details (masking the token for security)
416
- masked_params = {k: "***TOKEN***" if k == "access_token" else v for k, v in request_params.items()}
417
- print(f"Making {method} request to: {url}")
418
- print(f"Params: {masked_params}")
419
-
420
- async with httpx.AsyncClient() as client:
421
- try:
422
- if method == "GET":
423
- response = await client.get(url, params=request_params, headers=headers, timeout=30.0)
424
- elif method == "POST":
425
- response = await client.post(url, json=request_params, headers=headers, timeout=30.0)
426
- elif method == "DELETE":
427
- response = await client.delete(url, params=request_params, headers=headers, timeout=30.0)
428
- else:
429
- raise ValueError(f"Unsupported HTTP method: {method}")
430
-
431
- response.raise_for_status()
432
- return response.json()
433
-
434
- except httpx.HTTPStatusError as e:
435
- error_info = {}
436
- try:
437
- error_info = e.response.json()
438
- except:
439
- error_info = {"status_code": e.response.status_code, "text": e.response.text}
440
-
441
- print(f"HTTP Error: {e.response.status_code} - {error_info}")
442
-
443
- # Check for authentication errors
444
- if e.response.status_code == 401 or e.response.status_code == 403:
445
- print("Detected authentication error")
446
- auth_manager.invalidate_token()
447
- elif "error" in error_info:
448
- error_obj = error_info.get("error", {})
449
- # Check for specific FB API errors related to auth
450
- if isinstance(error_obj, dict) and error_obj.get("code") in [190, 102, 4, 200, 10]:
451
- print(f"Detected Facebook API auth error: {error_obj.get('code')}")
452
- # For app ID errors, provide more useful error message
453
- if error_obj.get("code") == 200 and "Provide valid app ID" in error_obj.get("message", ""):
454
- print("Meta API authentication configuration issue")
455
- app_id = auth_manager.app_id
456
- print(f"Current app_id: {app_id}")
457
- return {
458
- "error": f"HTTP Error: {e.response.status_code}",
459
- "details": {
460
- "message": "Meta API authentication configuration issue. Please check your app credentials.",
461
- "original_error": error_obj.get("message"),
462
- "code": error_obj.get("code")
463
- }
464
- }
465
- auth_manager.invalidate_token()
466
-
467
- return {"error": f"HTTP Error: {e.response.status_code}", "details": error_info}
468
-
469
- except Exception as e:
470
- print(f"Request Error: {str(e)}")
471
- return {"error": str(e)}
472
-
473
- # Generic wrapper for all Meta API tools
474
- def meta_api_tool(func):
475
- """Decorator to handle authentication for all Meta API tools"""
476
- async def wrapper(*args, **kwargs):
477
- global needs_authentication
478
- # Handle various MCP invocation patterns
479
- if len(args) == 1:
480
- # MCP might pass a single string argument that contains JSON
481
- if isinstance(args[0], str):
482
- try:
483
- # Try to parse the single string argument as JSON dictionary
484
- parsed_kwargs = json.loads(args[0]) if args[0] else {}
485
- # Clear args and use parsed_kwargs
486
- args = ()
487
- kwargs.update(parsed_kwargs)
488
- except Exception:
489
- pass
490
- # MCP might also pass a single dictionary argument
491
- elif isinstance(args[0], dict):
492
- # Treat the dict as kwargs
493
- kwargs.update(args[0])
494
- args = ()
495
-
496
- # Check if we have a 'kwargs' parameter, which means MCP is nesting the real parameters
497
- if 'kwargs' in kwargs and isinstance(kwargs['kwargs'], (str, dict)):
498
- # If it's a string, try to parse as JSON
499
- if isinstance(kwargs['kwargs'], str):
500
- try:
501
- parsed_inner_kwargs = json.loads(kwargs['kwargs']) if kwargs['kwargs'] else {}
502
- kwargs.update(parsed_inner_kwargs)
503
- except Exception:
504
- # If parsing fails, just keep the original kwargs
505
- pass
506
- # If it's already a dict, just update kwargs
507
- elif isinstance(kwargs['kwargs'], dict):
508
- kwargs.update(kwargs['kwargs'])
509
-
510
- # Remove the 'kwargs' parameter to avoid confusion
511
- del kwargs['kwargs']
512
-
513
- # Handle 'args' parameter if it exists
514
- if 'args' in kwargs:
515
- # We don't use positional args, so just remove it
516
- del kwargs['args']
517
-
518
- # Check if access_token is provided in kwargs
519
- access_token = kwargs.get('access_token')
520
-
521
- # If not, try to get it from the auth manager
522
- if not access_token:
523
- access_token = await get_current_access_token()
524
- if access_token:
525
- kwargs['access_token'] = access_token
526
-
527
- # If still no token, we need authentication
528
- if not access_token:
529
- needs_authentication = True
530
-
531
- # Check if we're using Pipeboard authentication
532
- using_pipeboard = bool(os.environ.get("PIPEBOARD_API_TOKEN", ""))
533
-
534
- if using_pipeboard:
535
- # For Pipeboard, we use a different authentication flow
536
- try:
537
- # Here we'd import dynamically to avoid circular imports
538
- from .core.pipeboard_auth import pipeboard_auth_manager
539
-
540
- # Initiate the Pipeboard auth flow
541
- auth_data = pipeboard_auth_manager.initiate_auth_flow()
542
- login_url = auth_data.get("loginUrl")
543
-
544
- # Return a user-friendly authentication required response for Pipeboard
545
- return json.dumps({
546
- "error": "Authentication Required",
547
- "details": {
548
- "message": "You need to authenticate with the Meta API via Pipeboard",
549
- "action_required": "Please authenticate using the link below",
550
- "login_url": login_url,
551
- "markdown_link": f"[Click here to authenticate with Meta Ads API via Pipeboard]({login_url})",
552
- "authentication_method": "pipeboard"
553
- }
554
- }, indent=2)
555
- except Exception as e:
556
- return json.dumps({
557
- "error": f"Pipeboard Authentication Error: {str(e)}",
558
- "details": {
559
- "message": "Failed to initiate Pipeboard authentication flow",
560
- "action_required": "Please check your PIPEBOARD_API_TOKEN environment variable"
561
- }
562
- }, indent=2)
563
- else:
564
- # For direct Meta auth, start the callback server
565
- port = start_callback_server()
566
-
567
- # Get current app ID from config
568
- current_app_id = meta_config.get_app_id()
569
- if not current_app_id:
570
- return json.dumps({
571
- "error": "No Meta App ID provided. Please provide a valid app ID via environment variable META_APP_ID or --app-id CLI argument.",
572
- "help": "This is required for authentication with Meta Graph API."
573
- }, indent=2)
574
-
575
- # Update auth manager with current app ID
576
- auth_manager.app_id = current_app_id
577
- print(f"Using Meta App ID from config: {current_app_id}")
578
-
579
- # Update auth manager's redirect URI with the current port
580
- auth_manager.redirect_uri = f"http://localhost:{port}/callback"
581
-
582
- # Generate the authentication URL
583
- login_url = auth_manager.get_auth_url()
584
-
585
- # Return a user-friendly authentication required response
586
- return json.dumps({
587
- "error": "Authentication Required",
588
- "details": {
589
- "message": "You need to authenticate with the Meta API before using this tool",
590
- "action_required": "Please authenticate using the link below",
591
- "login_url": login_url,
592
- "markdown_link": f"[Click here to authenticate with Meta Ads API]({login_url})",
593
- "authentication_method": "meta_oauth"
594
- }
595
- }, indent=2)
596
-
597
- # Call the original function
598
- try:
599
- result = await func(**kwargs)
600
-
601
- # If authentication is needed after the call (e.g., token was invalidated)
602
- if needs_authentication:
603
- # Check if we're using Pipeboard authentication
604
- using_pipeboard = bool(os.environ.get("PIPEBOARD_API_TOKEN", ""))
605
-
606
- if using_pipeboard:
607
- # For Pipeboard, we use a different authentication flow
608
- try:
609
- # Here we'd import dynamically to avoid circular imports
610
- from .core.pipeboard_auth import pipeboard_auth_manager
611
-
612
- # Initiate the Pipeboard auth flow
613
- auth_data = pipeboard_auth_manager.initiate_auth_flow()
614
- login_url = auth_data.get("loginUrl")
615
-
616
- # Create a resource response that includes the markdown link format
617
- response = {
618
- "error": "Session expired or token invalid. Please re-authenticate with Meta Ads API",
619
- "login_url": login_url,
620
- "markdown_link": f"[Click here to re-authenticate with Meta Ads API via Pipeboard]({login_url})",
621
- "message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
622
- "instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
623
- "authentication_method": "pipeboard",
624
- "note": "After authenticating, the token will be automatically saved."
625
- }
626
-
627
- return json.dumps(response, indent=2)
628
- except Exception as e:
629
- return json.dumps({
630
- "error": f"Pipeboard Authentication Error: {str(e)}",
631
- "details": {
632
- "message": "Failed to initiate Pipeboard authentication flow",
633
- "action_required": "Please check your PIPEBOARD_API_TOKEN environment variable"
634
- }
635
- }, indent=2)
636
- else:
637
- # For direct Meta auth, start the callback server
638
- port = start_callback_server()
639
-
640
- # Get current app ID from config
641
- current_app_id = meta_config.get_app_id()
642
- if not current_app_id:
643
- return json.dumps({
644
- "error": "No Meta App ID provided. Please provide a valid app ID via environment variable META_APP_ID or --app-id CLI argument.",
645
- "help": "This is required for authentication with Meta Graph API."
646
- }, indent=2)
647
-
648
- auth_manager.app_id = current_app_id
649
- print(f"Using Meta App ID from config: {current_app_id}")
650
-
651
- # Update auth manager's redirect URI with the current port
652
- auth_manager.redirect_uri = f"http://localhost:{port}/callback"
653
-
654
- # Generate the authentication URL
655
- login_url = auth_manager.get_auth_url()
656
-
657
- # Create a resource response that includes the markdown link format
658
- response = {
659
- "error": "Session expired or token invalid. Please re-authenticate with Meta Ads API",
660
- "login_url": login_url,
661
- "server_status": f"Callback server running on port {port}",
662
- "markdown_link": f"[Click here to re-authenticate with Meta Ads API]({login_url})",
663
- "message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
664
- "instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
665
- "authentication_method": "meta_oauth",
666
- "note": "After authenticating, the token will be automatically saved."
667
- }
668
-
669
- # Wait a moment to ensure the server is fully started
670
- await asyncio.sleep(1)
671
-
672
- return json.dumps(response, indent=2)
673
-
674
- # If result is a string (JSON), check for app ID errors and improve them
675
- if isinstance(result, str):
676
- try:
677
- result_obj = json.loads(result)
678
- if "error" in result_obj and "details" in result_obj and "error" in result_obj["details"]:
679
- error_obj = result_obj["details"].get("error", {})
680
- if isinstance(error_obj, dict) and error_obj.get("code") == 200 and "Provide valid app ID" in error_obj.get("message", ""):
681
- # Replace with more user-friendly message
682
- app_id = auth_manager.app_id
683
- return json.dumps({
684
- "error": "Meta API Configuration Issue",
685
- "details": {
686
- "message": "Your Meta API app is not properly configured",
687
- "action_required": "Check your META_APP_ID environment variable",
688
- "current_app_id": app_id,
689
- "original_error": error_obj.get("message")
690
- }
691
- }, indent=2)
692
- except Exception:
693
- # Not JSON or other parsing error, just continue
694
- pass
695
-
696
- return result
697
- except Exception as e:
698
- # Handle any unexpected errors
699
- error_result = {
700
- "error": f"Error calling Meta API: {str(e)}"
701
- }
702
- return json.dumps(error_result, indent=2)
703
-
704
- # Return the wrapper function with the same name and docstring
705
- wrapper.__name__ = func.__name__
706
- wrapper.__doc__ = func.__doc__
707
- return wrapper
708
-
709
- # Apply the decorator to tool functions
710
- @mcp_server.tool()
711
- @meta_api_tool
712
- async def get_ad_accounts(access_token: str = None, user_id: str = "me", limit: int = 10) -> str:
713
- """
714
- Get ad accounts accessible by a user.
715
-
716
- Args:
717
- access_token: Meta API access token (optional - will use cached token if not provided)
718
- user_id: Meta user ID or "me" for the current user
719
- limit: Maximum number of accounts to return (default: 10)
720
- """
721
- endpoint = f"{user_id}/adaccounts"
722
- params = {
723
- "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code",
724
- "limit": limit
725
- }
726
-
727
- data = await make_api_request(endpoint, access_token, params)
728
-
729
- return json.dumps(data, indent=2)
730
-
731
- @mcp_server.tool()
732
- @meta_api_tool
733
- async def get_account_info(access_token: str = None, account_id: str = None) -> str:
734
- """
735
- Get detailed information about a specific ad account.
736
-
737
- Args:
738
- access_token: Meta API access token (optional - will use cached token if not provided)
739
- account_id: Meta Ads account ID (format: act_XXXXXXXXX)
740
- """
741
- # If no account ID is specified, try to get the first one for the user
742
- if not account_id:
743
- accounts_json = await get_ad_accounts(access_token, limit=1)
744
- accounts_data = json.loads(accounts_json)
745
-
746
- if "data" in accounts_data and accounts_data["data"]:
747
- account_id = accounts_data["data"][0]["id"]
748
- else:
749
- return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
750
-
751
- # Ensure account_id has the 'act_' prefix for API compatibility
752
- if not account_id.startswith("act_"):
753
- account_id = f"act_{account_id}"
754
-
755
- endpoint = f"{account_id}"
756
- params = {
757
- "fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,funding_source_details,business_city,business_country_code,timezone_name,owner"
758
- }
759
-
760
- data = await make_api_request(endpoint, access_token, params)
761
-
762
- return json.dumps(data, indent=2)
763
-
764
- #
765
- # Campaign Endpoints
766
- #
767
-
768
- @mcp_server.tool()
769
- @meta_api_tool
770
- async def get_campaigns(access_token: str = None, account_id: str = None, limit: int = 10, status_filter: str = "", after: str = "") -> str:
771
- """
772
- Get campaigns for a Meta Ads account with optional filtering.
773
-
774
- Args:
775
- access_token: Meta API access token (optional - will use cached token if not provided)
776
- account_id: Meta Ads account ID (format: act_XXXXXXXXX)
777
- limit: Maximum number of campaigns to return (default: 10)
778
- status_filter: Filter by status (empty for all, or 'ACTIVE', 'PAUSED', etc.)
779
- after: Pagination cursor to get the next set of results
780
- """
781
- # If no account ID is specified, try to get the first one for the user
782
- if not account_id:
783
- accounts_json = await get_ad_accounts(access_token, limit=1)
784
- accounts_data = json.loads(accounts_json)
785
-
786
- if "data" in accounts_data and accounts_data["data"]:
787
- account_id = accounts_data["data"][0]["id"]
788
- else:
789
- return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
790
-
791
- endpoint = f"{account_id}/campaigns"
792
- params = {
793
- "fields": "id,name,objective,status,daily_budget,lifetime_budget,buying_type,start_time,stop_time,created_time,updated_time,bid_strategy",
794
- "limit": limit
795
- }
796
-
797
- if status_filter:
798
- params["effective_status"] = [status_filter]
799
-
800
- if after:
801
- params["after"] = after
802
-
803
- data = await make_api_request(endpoint, access_token, params)
804
-
805
- return json.dumps(data, indent=2)
806
-
807
- @mcp_server.tool()
808
- @meta_api_tool
809
- async def get_campaign_details(access_token: str = None, campaign_id: str = None) -> str:
810
- """
811
- Get detailed information about a specific campaign.
812
-
813
- Args:
814
- access_token: Meta API access token (optional - will use cached token if not provided)
815
- campaign_id: Meta Ads campaign ID
816
- """
817
- if not campaign_id:
818
- return json.dumps({"error": "No campaign ID provided"}, indent=2)
819
-
820
- endpoint = f"{campaign_id}"
821
- params = {
822
- "fields": "id,name,objective,status,daily_budget,lifetime_budget,buying_type,start_time,stop_time,created_time,updated_time,bid_strategy,special_ad_categories,special_ad_category_country,budget_remaining,configured_status"
823
- }
824
-
825
- data = await make_api_request(endpoint, access_token, params)
826
-
827
- return json.dumps(data, indent=2)
828
-
829
- @mcp_server.tool()
830
- @meta_api_tool
831
- async def create_campaign(
832
- access_token: str = None,
833
- account_id: str = None,
834
- name: str = None,
835
- objective: str = None,
836
- status: str = "PAUSED",
837
- special_ad_categories: List[str] = None,
838
- daily_budget: Optional[int] = None,
839
- lifetime_budget: Optional[int] = None
840
- ) -> str:
841
- """
842
- Create a new campaign in a Meta Ads account.
843
-
844
- Args:
845
- access_token: Meta API access token (optional - will use cached token if not provided)
846
- account_id: Meta Ads account ID (format: act_XXXXXXXXX)
847
- name: Campaign name
848
- objective: Campaign objective. enum{BRAND_AWARENESS, LEAD_GENERATION, LINK_CLICKS, CONVERSIONS, OUTCOME_TRAFFIC, etc.}.
849
- status: Initial campaign status (default: PAUSED)
850
- special_ad_categories: List of special ad categories if applicable
851
- daily_budget: Daily budget in account currency (in cents)
852
- lifetime_budget: Lifetime budget in account currency (in cents)
853
- """
854
- # Check required parameters
855
- if not account_id:
856
- return json.dumps({"error": "No account ID provided"}, indent=2)
857
-
858
- if not name:
859
- return json.dumps({"error": "No campaign name provided"}, indent=2)
860
-
861
- if not objective:
862
- return json.dumps({"error": "No campaign objective provided"}, indent=2)
863
-
864
- endpoint = f"{account_id}/campaigns"
865
-
866
- params = {
867
- "name": name,
868
- "objective": objective,
869
- "status": status,
870
- }
871
-
872
- if special_ad_categories:
873
- params["special_ad_categories"] = special_ad_categories
874
-
875
- if daily_budget:
876
- params["daily_budget"] = daily_budget
877
-
878
- if lifetime_budget:
879
- params["lifetime_budget"] = lifetime_budget
880
-
881
- data = await make_api_request(endpoint, access_token, params, method="POST")
882
-
883
- return json.dumps(data, indent=2)
884
-
885
- #
886
- # Ad Set Endpoints
887
- #
888
-
889
- @mcp_server.tool()
890
- @meta_api_tool
891
- async def get_adsets(access_token: str = None, account_id: str = None, limit: int = 10, campaign_id: str = "") -> str:
892
- """
893
- Get ad sets for a Meta Ads account with optional filtering by campaign.
894
-
895
- Args:
896
- access_token: Meta API access token (optional - will use cached token if not provided)
897
- account_id: Meta Ads account ID (format: act_XXXXXXXXX)
898
- limit: Maximum number of ad sets to return (default: 10)
899
- campaign_id: Optional campaign ID to filter by
900
- """
901
- # If no account ID is specified, try to get the first one for the user
902
- if not account_id:
903
- accounts_json = await get_ad_accounts(access_token, limit=1)
904
- accounts_data = json.loads(accounts_json)
905
-
906
- if "data" in accounts_data and accounts_data["data"]:
907
- account_id = accounts_data["data"][0]["id"]
908
- else:
909
- return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
910
-
911
- endpoint = f"{account_id}/adsets"
912
- params = {
913
- "fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time",
914
- "limit": limit
915
- }
916
-
917
- if campaign_id:
918
- params["campaign_id"] = campaign_id
919
-
920
- data = await make_api_request(endpoint, access_token, params)
921
-
922
- return json.dumps(data, indent=2)
923
-
924
- @mcp_server.tool()
925
- @meta_api_tool
926
- async def get_adset_details(access_token: str = None, adset_id: str = None) -> str:
927
- """
928
- Get detailed information about a specific ad set.
929
-
930
- Args:
931
- access_token: Meta API access token (optional - will use cached token if not provided)
932
- adset_id: Meta Ads ad set ID
933
- """
934
- if not adset_id:
935
- return json.dumps({"error": "No ad set ID provided"}, indent=2)
936
-
937
- endpoint = f"{adset_id}"
938
- params = {
939
- "fields": "id,name,campaign_id,status,daily_budget,lifetime_budget,targeting,bid_amount,bid_strategy,optimization_goal,billing_event,start_time,end_time,created_time,updated_time,attribution_spec,destination_type,promoted_object,pacing_type,budget_remaining"
940
- }
941
-
942
- data = await make_api_request(endpoint, access_token, params)
943
-
944
- return json.dumps(data, indent=2)
945
-
946
- #
947
- # Ad Endpoints
948
- #
949
-
950
- @mcp_server.tool()
951
- @meta_api_tool
952
- async def get_ads(
953
- access_token: str = None,
954
- account_id: str = None,
955
- limit: int = 10,
956
- campaign_id: str = "",
957
- adset_id: str = ""
958
- ) -> str:
959
- """
960
- Get ads for a Meta Ads account with optional filtering.
961
-
962
- Args:
963
- access_token: Meta API access token (optional - will use cached token if not provided)
964
- account_id: Meta Ads account ID (format: act_XXXXXXXXX)
965
- limit: Maximum number of ads to return (default: 10)
966
- campaign_id: Optional campaign ID to filter by
967
- adset_id: Optional ad set ID to filter by
968
- """
969
- # If no account ID is specified, try to get the first one for the user
970
- if not account_id:
971
- accounts_json = await get_ad_accounts(access_token=access_token, limit=1)
972
- accounts_data = json.loads(accounts_json)
973
-
974
- if "data" in accounts_data and accounts_data["data"]:
975
- account_id = accounts_data["data"][0]["id"]
976
- else:
977
- return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
978
-
979
- endpoint = f"{account_id}/ads"
980
- params = {
981
- "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
982
- "limit": limit
983
- }
984
-
985
- if campaign_id:
986
- params["campaign_id"] = campaign_id
987
-
988
- if adset_id:
989
- params["adset_id"] = adset_id
990
-
991
- data = await make_api_request(endpoint, access_token, params)
992
-
993
- return json.dumps(data, indent=2)
994
-
995
- @mcp_server.tool()
996
- @meta_api_tool
997
- async def get_ad_details(access_token: str = None, ad_id: str = None) -> str:
998
- """
999
- Get detailed information about a specific ad.
1000
-
1001
- Args:
1002
- access_token: Meta API access token (optional - will use cached token if not provided)
1003
- ad_id: Meta Ads ad ID
1004
- """
1005
- if not ad_id:
1006
- return json.dumps({"error": "No ad ID provided"}, indent=2)
1007
-
1008
- endpoint = f"{ad_id}"
1009
- params = {
1010
- "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs,preview_shareable_link"
1011
- }
1012
-
1013
- data = await make_api_request(endpoint, access_token, params)
1014
-
1015
- return json.dumps(data, indent=2)
1016
-
1017
- @mcp_server.tool()
1018
- @meta_api_tool
1019
- async def get_ad_creatives(access_token: str = None, ad_id: str = None) -> str:
1020
- """
1021
- Get creative details for a specific ad. Best if combined with get_ad_image to get the full image.
1022
-
1023
- Args:
1024
- access_token: Meta API access token (optional - will use cached token if not provided)
1025
- ad_id: Meta Ads ad ID
1026
- """
1027
- if not ad_id:
1028
- return json.dumps({"error": "No ad ID provided"}, indent=2)
1029
-
1030
- # First, get the creative ID from the ad
1031
- endpoint = f"{ad_id}"
1032
- params = {
1033
- "fields": "creative"
1034
- }
1035
-
1036
- ad_data = await make_api_request(endpoint, access_token, params)
1037
-
1038
- if "error" in ad_data:
1039
- return json.dumps(ad_data, indent=2)
1040
-
1041
- if "creative" not in ad_data:
1042
- return json.dumps({"error": "No creative found for this ad"}, indent=2)
1043
-
1044
- creative_id = ad_data.get("creative", {}).get("id")
1045
- if not creative_id:
1046
- return json.dumps({"error": "Creative ID not found", "ad_data": ad_data}, indent=2)
1047
-
1048
- # Now get the creative details with essential fields
1049
- creative_endpoint = f"{creative_id}"
1050
- creative_params = {
1051
- "fields": "id,name,title,body,image_url,object_story_spec,url_tags,link_url,thumbnail_url,image_hash,asset_feed_spec,object_type"
1052
- }
1053
-
1054
- creative_data = await make_api_request(creative_endpoint, access_token, creative_params)
1055
-
1056
- # Try to get full-size images in different ways:
1057
-
1058
- # 1. First approach: Get ad images directly using the adimages endpoint
1059
- if "image_hash" in creative_data:
1060
- image_hash = creative_data.get("image_hash")
1061
- image_endpoint = f"act_{ad_data.get('account_id', '')}/adimages"
1062
- image_params = {
1063
- "hashes": [image_hash]
1064
- }
1065
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1066
- if "data" in image_data and len(image_data["data"]) > 0:
1067
- creative_data["full_image_url"] = image_data["data"][0].get("url")
1068
-
1069
- # 2. For creatives with object_story_spec
1070
- if "object_story_spec" in creative_data:
1071
- spec = creative_data.get("object_story_spec", {})
1072
-
1073
- # For link ads
1074
- if "link_data" in spec:
1075
- link_data = spec.get("link_data", {})
1076
- # If there's an explicit image_url, use it
1077
- if "image_url" in link_data:
1078
- creative_data["full_image_url"] = link_data.get("image_url")
1079
- # If there's an image_hash, try to get the full image
1080
- elif "image_hash" in link_data:
1081
- image_hash = link_data.get("image_hash")
1082
- account_id = ad_data.get('account_id', '')
1083
- if not account_id:
1084
- # Try to get account ID from ad ID
1085
- ad_details_endpoint = f"{ad_id}"
1086
- ad_details_params = {
1087
- "fields": "account_id"
1088
- }
1089
- ad_details = await make_api_request(ad_details_endpoint, access_token, ad_details_params)
1090
- account_id = ad_details.get('account_id', '')
1091
-
1092
- if account_id:
1093
- image_endpoint = f"act_{account_id}/adimages"
1094
- image_params = {
1095
- "hashes": [image_hash]
1096
- }
1097
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1098
- if "data" in image_data and len(image_data["data"]) > 0:
1099
- creative_data["full_image_url"] = image_data["data"][0].get("url")
1100
-
1101
- # For photo ads
1102
- if "photo_data" in spec:
1103
- photo_data = spec.get("photo_data", {})
1104
- if "image_hash" in photo_data:
1105
- image_hash = photo_data.get("image_hash")
1106
- account_id = ad_data.get('account_id', '')
1107
- if not account_id:
1108
- # Try to get account ID from ad ID
1109
- ad_details_endpoint = f"{ad_id}"
1110
- ad_details_params = {
1111
- "fields": "account_id"
1112
- }
1113
- ad_details = await make_api_request(ad_details_endpoint, access_token, ad_details_params)
1114
- account_id = ad_details.get('account_id', '')
1115
-
1116
- if account_id:
1117
- image_endpoint = f"act_{account_id}/adimages"
1118
- image_params = {
1119
- "hashes": [image_hash]
1120
- }
1121
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1122
- if "data" in image_data and len(image_data["data"]) > 0:
1123
- creative_data["full_image_url"] = image_data["data"][0].get("url")
1124
-
1125
- # 3. If there's an asset_feed_spec, try to get images from there
1126
- if "asset_feed_spec" in creative_data and "images" in creative_data["asset_feed_spec"]:
1127
- images = creative_data["asset_feed_spec"]["images"]
1128
- if images and len(images) > 0 and "hash" in images[0]:
1129
- image_hash = images[0]["hash"]
1130
- account_id = ad_data.get('account_id', '')
1131
- if not account_id:
1132
- # Try to get account ID
1133
- ad_details_endpoint = f"{ad_id}"
1134
- ad_details_params = {
1135
- "fields": "account_id"
1136
- }
1137
- ad_details = await make_api_request(ad_details_endpoint, access_token, ad_details_params)
1138
- account_id = ad_details.get('account_id', '')
1139
-
1140
- if account_id:
1141
- image_endpoint = f"act_{account_id}/adimages"
1142
- image_params = {
1143
- "hashes": [image_hash]
1144
- }
1145
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1146
- if "data" in image_data and len(image_data["data"]) > 0:
1147
- creative_data["full_image_url"] = image_data["data"][0].get("url")
1148
-
1149
- # If we have a thumbnail_url but no full_image_url, let's attempt to convert the thumbnail URL to full size
1150
- if "thumbnail_url" in creative_data and "full_image_url" not in creative_data:
1151
- thumbnail_url = creative_data["thumbnail_url"]
1152
- # Try to convert the URL to get higher resolution by removing size parameters
1153
- if "p64x64" in thumbnail_url:
1154
- full_url = thumbnail_url.replace("p64x64", "p1080x1080")
1155
- creative_data["full_image_url"] = full_url
1156
- elif "dst-emg0" in thumbnail_url:
1157
- # Remove the dst-emg0 parameter that seems to reduce size
1158
- full_url = thumbnail_url.replace("dst-emg0_", "")
1159
- creative_data["full_image_url"] = full_url
1160
-
1161
- # Fallback to using thumbnail or image_url if we still don't have a full image
1162
- if "full_image_url" not in creative_data:
1163
- if "thumbnail_url" in creative_data:
1164
- creative_data["full_image_url"] = creative_data["thumbnail_url"]
1165
- elif "image_url" in creative_data:
1166
- creative_data["full_image_url"] = creative_data["image_url"]
1167
-
1168
- return json.dumps(creative_data, indent=2)
1169
-
1170
- @mcp_server.tool()
1171
- @meta_api_tool
1172
- async def get_ad_image(access_token: str = None, ad_id: str = None) -> Image:
1173
- """
1174
- Get, download, and visualize a Meta ad image in one step. Useful to see the image in the LLM.
1175
-
1176
- Args:
1177
- access_token: Meta API access token (optional - will use cached token if not provided)
1178
- ad_id: Meta Ads ad ID
1179
-
1180
- Returns:
1181
- The ad image ready for direct visual analysis
1182
- """
1183
- if not ad_id:
1184
- return "Error: No ad ID provided"
1185
-
1186
- print(f"Attempting to get and analyze creative image for ad {ad_id}")
1187
-
1188
- # First, get creative and account IDs
1189
- ad_endpoint = f"{ad_id}"
1190
- ad_params = {
1191
- "fields": "creative{id},account_id"
1192
- }
1193
-
1194
- ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
1195
-
1196
- if "error" in ad_data:
1197
- return f"Error: Could not get ad data - {json.dumps(ad_data)}"
1198
-
1199
- # Extract account_id
1200
- account_id = ad_data.get("account_id", "")
1201
- if not account_id:
1202
- return "Error: No account ID found"
1203
-
1204
- # Extract creative ID
1205
- if "creative" not in ad_data:
1206
- return "Error: No creative found for this ad"
1207
-
1208
- creative_data = ad_data.get("creative", {})
1209
- creative_id = creative_data.get("id")
1210
- if not creative_id:
1211
- return "Error: No creative ID found"
1212
-
1213
- # Get creative details to find image hash
1214
- creative_endpoint = f"{creative_id}"
1215
- creative_params = {
1216
- "fields": "id,name,image_hash,asset_feed_spec"
1217
- }
1218
-
1219
- creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
1220
-
1221
- # Identify image hashes to use from creative
1222
- image_hashes = []
1223
-
1224
- # Check for direct image_hash on creative
1225
- if "image_hash" in creative_details:
1226
- image_hashes.append(creative_details["image_hash"])
1227
-
1228
- # Check asset_feed_spec for image hashes - common in Advantage+ ads
1229
- if "asset_feed_spec" in creative_details and "images" in creative_details["asset_feed_spec"]:
1230
- for image in creative_details["asset_feed_spec"]["images"]:
1231
- if "hash" in image:
1232
- image_hashes.append(image["hash"])
1233
-
1234
- if not image_hashes:
1235
- # If no hashes found, try to extract from the first creative we found in the API
1236
- # Get creative for ad to try to extract hash
1237
- creative_json = await get_ad_creatives(access_token, ad_id)
1238
- creative_data = json.loads(creative_json)
1239
-
1240
- # Try to extract hash from asset_feed_spec
1241
- if "asset_feed_spec" in creative_data and "images" in creative_data["asset_feed_spec"]:
1242
- images = creative_data["asset_feed_spec"]["images"]
1243
- if images and len(images) > 0 and "hash" in images[0]:
1244
- image_hashes.append(images[0]["hash"])
1245
-
1246
- if not image_hashes:
1247
- return "Error: No image hashes found in creative"
1248
-
1249
- print(f"Found image hashes: {image_hashes}")
1250
-
1251
- # Now fetch image data using adimages endpoint with specific format
1252
- image_endpoint = f"act_{account_id}/adimages"
1253
-
1254
- # Format the hashes parameter exactly as in our successful curl test
1255
- hashes_str = f'["{image_hashes[0]}"]' # Format first hash only, as JSON string array
1256
-
1257
- image_params = {
1258
- "fields": "hash,url,width,height,name,status",
1259
- "hashes": hashes_str
1260
- }
1261
-
1262
- print(f"Requesting image data with params: {image_params}")
1263
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1264
-
1265
- if "error" in image_data:
1266
- return f"Error: Failed to get image data - {json.dumps(image_data)}"
1267
-
1268
- if "data" not in image_data or not image_data["data"]:
1269
- return "Error: No image data returned from API"
1270
-
1271
- # Get the first image URL
1272
- first_image = image_data["data"][0]
1273
- image_url = first_image.get("url")
1274
-
1275
- if not image_url:
1276
- return "Error: No valid image URL found"
1277
-
1278
- print(f"Downloading image from URL: {image_url}")
1279
-
1280
- # Download the image
1281
- image_bytes = await download_image(image_url)
1282
-
1283
- if not image_bytes:
1284
- return "Error: Failed to download image"
1285
-
1286
- try:
1287
- # Convert bytes to PIL Image
1288
- img = PILImage.open(io.BytesIO(image_bytes))
1289
-
1290
- # Convert to RGB if needed
1291
- if img.mode != "RGB":
1292
- img = img.convert("RGB")
1293
-
1294
- # Create a byte stream of the image data
1295
- byte_arr = io.BytesIO()
1296
- img.save(byte_arr, format="JPEG")
1297
- img_bytes = byte_arr.getvalue()
1298
-
1299
- # Return as an Image object that LLM can directly analyze
1300
- return Image(data=img_bytes, format="jpeg")
1301
-
1302
- except Exception as e:
1303
- return f"Error processing image: {str(e)}"
1304
-
1305
- # Resource Handling
1306
- @mcp_server.resource(uri="meta-ads://resources")
1307
- async def list_resources() -> Dict[str, Any]:
1308
- """List all available resources (like ad creative images)"""
1309
- resources = []
1310
-
1311
- # Add all ad creative images as resources
1312
- for resource_id, image_info in ad_creative_images.items():
1313
- resources.append({
1314
- "uri": f"meta-ads://images/{resource_id}",
1315
- "mimeType": image_info["mime_type"],
1316
- "name": image_info["name"]
1317
- })
1318
-
1319
- return {"resources": resources}
1320
-
1321
- @mcp_server.resource(uri="meta-ads://images/{resource_id}")
1322
- async def get_resource(resource_id: str) -> Dict[str, Any]:
1323
- """Get a specific resource by URI"""
1324
- if resource_id in ad_creative_images:
1325
- image_info = ad_creative_images[resource_id]
1326
- return {
1327
- "data": base64.b64encode(image_info["data"]).decode("utf-8"),
1328
- "mimeType": image_info["mime_type"]
1329
- }
1330
-
1331
- # Resource not found
1332
- return {"error": f"Resource not found: {resource_id}"}
1333
-
1334
- #
1335
- # Insights Endpoints
1336
- #
1337
-
1338
- @mcp_server.tool()
1339
- @meta_api_tool
1340
- async def get_insights(
1341
- access_token: str = None,
1342
- object_id: str = None,
1343
- time_range: str = "maximum",
1344
- breakdown: str = "",
1345
- level: str = "ad"
1346
- ) -> str:
1347
- """
1348
- Get performance insights for a campaign, ad set, ad or account.
1349
-
1350
- Args:
1351
- access_token: Meta API access token (optional - will use cached token if not provided)
1352
- object_id: ID of the campaign, ad set, ad or account
1353
- time_range: Time range for insights (default: last_30_days, options: today, yesterday, this_month, last_month, this_quarter, maximum, data_maximum, last_3d, last_7d, last_14d, last_28d, last_30d, last_90d, last_week_mon_sun, last_week_sun_sat, last_quarter, last_year, this_week_mon_today, this_week_sun_today, this_year)
1354
- breakdown: Optional breakdown dimension (e.g., age, gender, country)
1355
- level: Level of aggregation (ad, adset, campaign, account)
1356
- """
1357
- # Import logger
1358
- from meta_ads_mcp.core.utils import logger
1359
-
1360
- # Log function call details
1361
- logger.info(f"get_insights called with object_id: {object_id}, time_range: {time_range}, level: {level}")
1362
-
1363
- # Log authentication details
1364
- from meta_ads_mcp.core.auth import meta_config
1365
- app_id = meta_config.get_app_id()
1366
- logger.info(f"App ID from meta_config: {app_id}")
1367
-
1368
- # Validate inputs
1369
- if not object_id:
1370
- logger.error("No object ID provided for get_insights")
1371
- return json.dumps({"error": "Missing Required Parameter", "details": {"message": "No object ID provided"}}, indent=2)
1372
-
1373
- # Check access token (masking for security)
1374
- token_status = "provided" if access_token else "not provided"
1375
- logger.info(f"Access token status: {token_status}")
1376
-
1377
- # Log the specific object ID format for troubleshooting
1378
- if object_id.startswith("act_"):
1379
- logger.info(f"Object ID is an account ID: {object_id}")
1380
- else:
1381
- logger.info(f"Object ID format: {object_id}")
1382
-
1383
- endpoint = f"{object_id}/insights"
1384
- logger.info(f"Using endpoint: {endpoint}")
1385
-
1386
- params = {
1387
- "date_preset": time_range,
1388
- "fields": "account_id,account_name,campaign_id,campaign_name,adset_id,adset_name,ad_id,ad_name,impressions,clicks,spend,cpc,cpm,ctr,reach,frequency,actions,conversions,unique_clicks,cost_per_action_type",
1389
- "level": level
1390
- }
1391
-
1392
- if breakdown:
1393
- params["breakdowns"] = breakdown
1394
-
1395
- logger.info("Making API request for insights")
1396
- try:
1397
- data = await make_api_request(endpoint, access_token, params)
1398
- logger.info(f"API response received: {'success' if 'error' not in data else 'error'}")
1399
-
1400
- # Check for specific app ID errors and improve error message
1401
- if "error" in data:
1402
- error_details = data.get("details", {}).get("error", {})
1403
- if isinstance(error_details, dict) and error_details.get("code") == 200:
1404
- logger.error(f"Authentication configuration error in response: {error_details.get('message')}")
1405
- return json.dumps({
1406
- "error": "Meta API Configuration Issue",
1407
- "details": {
1408
- "message": "There is an issue with your Meta API configuration",
1409
- "action_required": "Check your META_APP_ID environment variable or re-authenticate",
1410
- "current_app_id": app_id
1411
- }
1412
- }, indent=2)
1413
- except Exception as e:
1414
- logger.error(f"Exception during get_insights API call: {str(e)}")
1415
- data = {"error": str(e)}
1416
-
1417
- return json.dumps(data, indent=2)
1418
-
1419
- @mcp_server.tool()
1420
- @meta_api_tool
1421
- async def debug_image_download(url="", ad_id="", access_token=None):
1422
- """Debug image download issues and report detailed diagnostics."""
1423
- results = {}
1424
-
1425
- if url:
1426
- results["image_url"] = url
1427
- else:
1428
- # If no URL provided but ad_id is, get URL from ad creative
1429
- if ad_id:
1430
- print(f"Getting image URL from ad creative for ad {ad_id}")
1431
- # Get the creative details
1432
- creative_json = await get_ad_creatives(access_token=access_token, ad_id=ad_id)
1433
- creative_data = json.loads(creative_json)
1434
- results["creative_data"] = creative_data
1435
-
1436
- # Look for image URL in the creative
1437
- if "full_image_url" in creative_data:
1438
- url = creative_data.get("full_image_url")
1439
- elif "thumbnail_url" in creative_data:
1440
- url = creative_data.get("thumbnail_url")
1441
-
1442
- if not url:
1443
- return json.dumps({
1444
- "error": "No image URL provided or found in ad creative",
1445
- "results": results
1446
- }, indent=2)
1447
-
1448
- results["image_url"] = url
1449
-
1450
- # Method 1: Basic download
1451
- method_result = {
1452
- "method": "Basic download with standard headers",
1453
- "success": False
1454
- }
1455
- results["diagnostics"] = {"methods_tried": [method_result]}
1456
-
1457
- try:
1458
- headers = {
1459
- "User-Agent": USER_AGENT
1460
- }
1461
- async with httpx.AsyncClient(follow_redirects=True) as client:
1462
- response = await client.get(url, headers=headers, timeout=30.0)
1463
- method_result["status_code"] = response.status_code
1464
- method_result["headers"] = dict(response.headers)
1465
-
1466
- if response.status_code == 200:
1467
- method_result["success"] = True
1468
- method_result["content_length"] = len(response.content)
1469
- method_result["content_type"] = response.headers.get("content-type")
1470
-
1471
- # Save this successful result
1472
- results["image_data"] = {
1473
- "length": len(response.content),
1474
- "type": response.headers.get("content-type"),
1475
- "base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
1476
- }
1477
- except Exception as e:
1478
- method_result["error"] = str(e)
1479
-
1480
- # Method 2: Browser emulation
1481
- method_result = {
1482
- "method": "Browser emulation with cookies",
1483
- "success": False
1484
- }
1485
- results["diagnostics"]["methods_tried"].append(method_result)
1486
-
1487
- try:
1488
- headers = {
1489
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
1490
- "Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
1491
- "Accept-Language": "en-US,en;q=0.9",
1492
- "Referer": "https://www.facebook.com/",
1493
- "Cookie": "presence=EDvF3EtimeF1697900316EuserFA21B00112233445566AA0EstateFDutF0CEchF_7bCC"
1494
- }
1495
-
1496
- async with httpx.AsyncClient(follow_redirects=True) as client:
1497
- response = await client.get(url, headers=headers, timeout=30.0)
1498
- method_result["status_code"] = response.status_code
1499
- method_result["headers"] = dict(response.headers)
1500
-
1501
- if response.status_code == 200:
1502
- method_result["success"] = True
1503
- method_result["content_length"] = len(response.content)
1504
- method_result["content_type"] = response.headers.get("content-type")
1505
-
1506
- # If first method didn't succeed, save this successful result
1507
- if "image_data" not in results:
1508
- results["image_data"] = {
1509
- "length": len(response.content),
1510
- "type": response.headers.get("content-type"),
1511
- "base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
1512
- }
1513
- except Exception as e:
1514
- method_result["error"] = str(e)
1515
-
1516
- # Method 3: Graph API direct access (if applicable)
1517
- if "fbcdn" in url or "facebook" in url:
1518
- method_result = {
1519
- "method": "Graph API direct access",
1520
- "success": False
1521
- }
1522
- results["diagnostics"]["methods_tried"].append(method_result)
1523
-
1524
- try:
1525
- # Try to reconstruct the attachment ID from URL if possible
1526
- url_parts = url.split("/")
1527
- potential_ids = [part for part in url_parts if part.isdigit() and len(part) > 10]
1528
-
1529
- if ad_id and potential_ids:
1530
- attachment_id = potential_ids[0]
1531
- endpoint = f"{attachment_id}?fields=url,width,height"
1532
- api_result = await make_api_request(endpoint, access_token)
1533
-
1534
- method_result["api_response"] = api_result
1535
-
1536
- if "url" in api_result:
1537
- graph_url = api_result["url"]
1538
- method_result["graph_url"] = graph_url
1539
-
1540
- # Try to download from this Graph API URL
1541
- async with httpx.AsyncClient() as client:
1542
- response = await client.get(graph_url, timeout=30.0)
1543
-
1544
- method_result["status_code"] = response.status_code
1545
- if response.status_code == 200:
1546
- method_result["success"] = True
1547
- method_result["content_length"] = len(response.content)
1548
-
1549
- # If previous methods didn't succeed, save this successful result
1550
- if "image_data" not in results:
1551
- results["image_data"] = {
1552
- "length": len(response.content),
1553
- "type": response.headers.get("content-type"),
1554
- "base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
1555
- }
1556
- except Exception as e:
1557
- method_result["error"] = str(e)
1558
-
1559
- # Generate a recommendation based on what we found
1560
- if "image_data" in results:
1561
- results["recommendation"] = "At least one download method succeeded. Consider implementing the successful method in the main code."
1562
- else:
1563
- # Check if the error appears to be access-related
1564
- access_errors = False
1565
- for method in results["diagnostics"]["methods_tried"]:
1566
- if method.get("status_code") in [401, 403, 503]:
1567
- access_errors = True
1568
-
1569
- if access_errors:
1570
- results["recommendation"] = "Authentication or authorization errors detected. Images may require direct Facebook authentication not possible via API."
1571
- else:
1572
- results["recommendation"] = "Network or other technical errors detected. Check URL expiration or CDN restrictions."
1573
-
1574
- return json.dumps(results, indent=2)
1575
-
1576
- @mcp_server.tool()
1577
- @meta_api_tool
1578
- async def save_ad_image_via_api(access_token: str = None, ad_id: str = None) -> str:
1579
- """
1580
- Try to save an ad image by using the Marketing API's attachment endpoints.
1581
- This is an alternative approach when direct image download fails.
1582
-
1583
- Args:
1584
- access_token: Meta API access token (optional - will use cached token if not provided)
1585
- ad_id: Meta Ads ad ID
1586
- """
1587
- if not ad_id:
1588
- return json.dumps({"error": "No ad ID provided"}, indent=2)
1589
-
1590
- print(f"Attempting to save image for ad {ad_id} using API methods")
1591
-
1592
- # First, get creative ID and account ID
1593
- ad_endpoint = f"{ad_id}"
1594
- ad_params = {
1595
- "fields": "creative{id},account_id"
1596
- }
1597
-
1598
- ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
1599
-
1600
- if "error" in ad_data:
1601
- return json.dumps({"error": f"Could not get ad data - {ad_data['error']}"}, indent=2)
1602
-
1603
- # Extract account_id
1604
- account_id = ad_data.get("account_id", "")
1605
- if not account_id:
1606
- return json.dumps({"error": "No account ID found"}, indent=2)
1607
-
1608
- # Extract creative ID
1609
- if "creative" not in ad_data:
1610
- return json.dumps({"error": "No creative found for this ad"}, indent=2)
1611
-
1612
- creative_data = ad_data.get("creative", {})
1613
- creative_id = creative_data.get("id")
1614
- if not creative_id:
1615
- return json.dumps({"error": "No creative ID found"}, indent=2)
1616
-
1617
- # Get creative details to find image hash
1618
- creative_endpoint = f"{creative_id}"
1619
- creative_params = {
1620
- "fields": "id,name,image_hash,thumbnail_url,image_url,object_story_spec"
1621
- }
1622
-
1623
- creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
1624
-
1625
- if "error" in creative_details:
1626
- return json.dumps({"error": f"Could not get creative details - {creative_details['error']}"}, indent=2)
1627
-
1628
- results = {
1629
- "ad_id": ad_id,
1630
- "creative_id": creative_id,
1631
- "account_id": account_id,
1632
- "creative_details": creative_details
1633
- }
1634
-
1635
- # Try to find image hash
1636
- image_hash = None
1637
-
1638
- # Direct hash on creative
1639
- if "image_hash" in creative_details:
1640
- image_hash = creative_details["image_hash"]
1641
-
1642
- # Look in object_story_spec
1643
- elif "object_story_spec" in creative_details:
1644
- spec = creative_details["object_story_spec"]
1645
-
1646
- # For link ads
1647
- if "link_data" in spec:
1648
- link_data = spec["link_data"]
1649
- if "image_hash" in link_data:
1650
- image_hash = link_data["image_hash"]
1651
-
1652
- # For photo ads
1653
- elif "photo_data" in spec:
1654
- photo_data = spec["photo_data"]
1655
- if "image_hash" in photo_data:
1656
- image_hash = photo_data["image_hash"]
1657
-
1658
- if not image_hash:
1659
- return json.dumps({
1660
- "error": "No image hash found in creative",
1661
- "creative_details": creative_details
1662
- }, indent=2)
1663
-
1664
- # Now get image data from the adimages endpoint
1665
- image_endpoint = f"act_{account_id}/adimages"
1666
- image_params = {
1667
- "hashes": [image_hash]
1668
- }
1669
-
1670
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1671
-
1672
- if "error" in image_data:
1673
- return json.dumps({
1674
- "error": f"Failed to get image data - {image_data['error']}",
1675
- "hash": image_hash
1676
- }, indent=2)
1677
-
1678
- if "data" not in image_data or not image_data["data"]:
1679
- return json.dumps({
1680
- "error": "No image data returned from API",
1681
- "hash": image_hash
1682
- }, indent=2)
1683
-
1684
- # Get the URL from the first image
1685
- first_image = image_data["data"][0]
1686
- image_url = first_image.get("url")
1687
-
1688
- if not image_url:
1689
- return json.dumps({
1690
- "error": "No image URL found in API response",
1691
- "api_response": image_data
1692
- }, indent=2)
1693
-
1694
- # Try to save the image by directly downloading it
1695
- results["image_url"] = image_url
1696
-
1697
- try:
1698
- # Try multiple download methods
1699
- image_bytes = await try_multiple_download_methods(image_url)
1700
-
1701
- if not image_bytes:
1702
- return json.dumps({
1703
- "error": "Failed to download image from URL provided by API",
1704
- "image_url": image_url,
1705
- "suggestion": "Try using the debug_image_download tool for more details"
1706
- }, indent=2)
1707
-
1708
- # Create a resource ID for this image
1709
- resource_id = f"ad_{ad_id}_{int(time.time())}"
1710
-
1711
- # Store the image
1712
- img = PILImage.open(io.BytesIO(image_bytes))
1713
- mime_type = f"image/{img.format.lower()}" if img.format else "image/jpeg"
1714
-
1715
- # Save to our global dictionary
1716
- ad_creative_images[resource_id] = {
1717
- "data": image_bytes,
1718
- "mime_type": mime_type,
1719
- "name": f"Ad {ad_id} Image",
1720
- "width": img.width,
1721
- "height": img.height,
1722
- "format": img.format
1723
- }
1724
-
1725
- # Return the success result with resource info
1726
- return json.dumps({
1727
- "success": True,
1728
- "message": "Successfully saved image",
1729
- "resource_id": resource_id,
1730
- "resource_uri": f"meta-ads://images/{resource_id}",
1731
- "image_details": {
1732
- "width": img.width,
1733
- "height": img.height,
1734
- "format": img.format,
1735
- "size_bytes": len(image_bytes)
1736
- }
1737
- }, indent=2)
1738
-
1739
- except Exception as e:
1740
- return json.dumps({
1741
- "error": f"Error saving image: {str(e)}",
1742
- "image_url": image_url
1743
- }, indent=2)
1744
-
1745
- # Helper function to start the login flow
1746
- def login():
1747
- """
1748
- Start the login flow to authenticate with Meta
1749
- """
1750
- print("Starting Meta Ads authentication flow...")
1751
-
1752
- # Ensure auth_manager has the current app ID from config
1753
- current_app_id = meta_config.get_app_id()
1754
- if not current_app_id:
1755
- print("Error: No Meta App ID available. Authentication will fail.")
1756
- print("Please provide an app ID using --app-id or via META_APP_ID environment variable.")
1757
- return
1758
-
1759
- auth_manager.app_id = current_app_id
1760
- print(f"Using Meta App ID from config: {current_app_id}")
1761
-
1762
- try:
1763
- # Start the callback server first
1764
- port = start_callback_server()
1765
-
1766
- # Get the auth URL and open the browser
1767
- auth_url = auth_manager.get_auth_url()
1768
- print(f"Opening browser with URL: {auth_url}")
1769
- webbrowser.open(auth_url)
1770
-
1771
- # Wait for token to be received
1772
- print("Waiting for authentication to complete...")
1773
- max_wait = 300 # 5 minutes
1774
- wait_interval = 2 # 2 seconds
1775
-
1776
- for _ in range(max_wait // wait_interval):
1777
- if token_container["token"]:
1778
- token = token_container["token"]
1779
- print("Authentication successful!")
1780
- # Verify token works by getting basic user info
1781
- try:
1782
- result = asyncio.run(make_api_request("me", token, {}))
1783
- print(f"Authenticated as: {result.get('name', 'Unknown')} (ID: {result.get('id', 'Unknown')})")
1784
- return
1785
- except Exception as e:
1786
- print(f"Warning: Could not verify token: {e}")
1787
- return
1788
- time.sleep(wait_interval)
1789
-
1790
- print("Authentication timed out. Please try again.")
1791
- except Exception as e:
1792
- print(f"Error during authentication: {e}")
1793
- print(f"Direct authentication URL: {auth_manager.get_auth_url()}")
1794
- print("You can manually open this URL in your browser to complete authentication.")
1795
-
1796
- async def download_image(url: str) -> Optional[bytes]:
1797
- """
1798
- Download an image from a URL.
1799
-
1800
- Args:
1801
- url: Image URL
1802
-
1803
- Returns:
1804
- Image data as bytes if successful, None otherwise
1805
- """
1806
- try:
1807
- print(f"Attempting to download image from URL: {url}")
1808
-
1809
- # Use minimal headers like curl does
1810
- headers = {
1811
- "User-Agent": "curl/8.4.0",
1812
- "Accept": "*/*"
1813
- }
1814
-
1815
- # Ensure the URL is properly escaped
1816
- # But don't modify the already encoded parameters
1817
-
1818
- async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
1819
- # Simple GET request just like curl
1820
- response = await client.get(url, headers=headers)
1821
-
1822
- # Check response
1823
- if response.status_code == 200:
1824
- print(f"Successfully downloaded image: {len(response.content)} bytes")
1825
- return response.content
1826
- else:
1827
- print(f"Failed to download image: HTTP {response.status_code}")
1828
- return None
1829
-
1830
- except httpx.HTTPStatusError as e:
1831
- print(f"HTTP Error when downloading image: {e}")
1832
- return None
1833
- except httpx.RequestError as e:
1834
- print(f"Request Error when downloading image: {e}")
1835
- return None
1836
- except Exception as e:
1837
- print(f"Unexpected error downloading image: {e}")
1838
- return None
1839
-
1840
- # Add a new helper function to try different download methods
1841
- async def try_multiple_download_methods(url: str) -> Optional[bytes]:
1842
- """
1843
- Try multiple methods to download an image, with different approaches for Meta CDN.
1844
-
1845
- Args:
1846
- url: Image URL
1847
-
1848
- Returns:
1849
- Image data as bytes if successful, None otherwise
1850
- """
1851
- # Method 1: Direct download with custom headers
1852
- image_data = await download_image(url)
1853
- if image_data:
1854
- return image_data
1855
-
1856
- print("Direct download failed, trying alternative methods...")
1857
-
1858
- # Method 2: Try adding Facebook cookie simulation
1859
- try:
1860
- headers = {
1861
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
1862
- "Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
1863
- "Cookie": "presence=EDvF3EtimeF1697900316EuserFA21B00112233445566AA0EstateFDutF0CEchF_7bCC" # Fake cookie
1864
- }
1865
-
1866
- async with httpx.AsyncClient(follow_redirects=True) as client:
1867
- response = await client.get(url, headers=headers, timeout=30.0)
1868
- response.raise_for_status()
1869
- print(f"Method 2 succeeded with cookie simulation: {len(response.content)} bytes")
1870
- return response.content
1871
- except Exception as e:
1872
- print(f"Method 2 failed: {str(e)}")
1873
-
1874
- # Method 3: Try with session that keeps redirects and cookies
1875
- try:
1876
- async with httpx.AsyncClient(follow_redirects=True) as client:
1877
- # First visit Facebook to get cookies
1878
- await client.get("https://www.facebook.com/", timeout=30.0)
1879
- # Then try the image URL
1880
- response = await client.get(url, timeout=30.0)
1881
- response.raise_for_status()
1882
- print(f"Method 3 succeeded with Facebook session: {len(response.content)} bytes")
1883
- return response.content
1884
- except Exception as e:
1885
- print(f"Method 3 failed: {str(e)}")
1886
-
1887
- return None
1888
-
1889
- def shutdown_callback_server():
1890
- """Shutdown the callback server if it's running"""
1891
- global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
1892
-
1893
- with callback_server_lock:
1894
- if not callback_server_running:
1895
- print("Callback server is not running")
1896
- return
1897
-
1898
- if server_shutdown_timer is not None:
1899
- server_shutdown_timer.cancel()
1900
- server_shutdown_timer = None
1901
-
1902
- print(f"Shutting down callback server on port {callback_server_port}")
1903
-
1904
- # Shutdown the server if it exists
1905
- if callback_server_instance:
1906
- try:
1907
- callback_server_instance.shutdown()
1908
- callback_server_instance = None
1909
- callback_server_running = False
1910
- print("Callback server has been shut down")
1911
- except Exception as e:
1912
- print(f"Error shutting down callback server: {e}")
1913
- else:
1914
- print("No server instance to shut down")
1915
-
1916
- def start_callback_server():
1917
- """Start the callback server if it's not already running"""
1918
- global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
1919
-
1920
- with callback_server_lock:
1921
- if callback_server_running:
1922
- print(f"Callback server already running on port {callback_server_port}")
1923
-
1924
- # Reset the shutdown timer if one exists
1925
- if server_shutdown_timer is not None:
1926
- server_shutdown_timer.cancel()
1927
-
1928
- server_shutdown_timer = threading.Timer(CALLBACK_SERVER_TIMEOUT, shutdown_callback_server)
1929
- server_shutdown_timer.daemon = True
1930
- server_shutdown_timer.start()
1931
- print(f"Reset server shutdown timer to {CALLBACK_SERVER_TIMEOUT} seconds")
1932
-
1933
- return callback_server_port
1934
-
1935
- # Find an available port
1936
- port = 8888
1937
- max_attempts = 10
1938
- for attempt in range(max_attempts):
1939
- try:
1940
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1941
- s.bind(('localhost', port))
1942
- break
1943
- except OSError:
1944
- port += 1
1945
- if attempt == max_attempts - 1:
1946
- raise Exception(f"Could not find an available port after {max_attempts} attempts")
1947
-
1948
- # Update auth manager's redirect URI with new port
1949
- auth_manager.redirect_uri = f"http://localhost:{port}/callback"
1950
-
1951
- # Always make sure auth_manager has the current app ID from config
1952
- current_app_id = meta_config.get_app_id()
1953
- if current_app_id:
1954
- auth_manager.app_id = current_app_id
1955
-
1956
- callback_server_port = port
1957
-
1958
- try:
1959
- # Get the CallbackHandler class from global scope
1960
- handler_class = globals()['CallbackHandler']
1961
-
1962
- # Create and start server in a daemon thread
1963
- server = HTTPServer(('localhost', port), handler_class)
1964
- callback_server_instance = server
1965
- print(f"Callback server starting on port {port}")
1966
-
1967
- # Create a simple flag to signal when the server is ready
1968
- server_ready = threading.Event()
1969
-
1970
- def server_thread():
1971
- try:
1972
- # Signal that the server thread has started
1973
- server_ready.set()
1974
- print(f"Callback server is now ready on port {port}")
1975
- # Start serving HTTP requests
1976
- server.serve_forever()
1977
- except Exception as e:
1978
- print(f"Server error: {e}")
1979
- finally:
1980
- with callback_server_lock:
1981
- global callback_server_running
1982
- callback_server_running = False
1983
-
1984
- callback_server_thread = threading.Thread(target=server_thread)
1985
- callback_server_thread.daemon = True
1986
- callback_server_thread.start()
1987
-
1988
- # Wait for server to be ready (up to 5 seconds)
1989
- if not server_ready.wait(timeout=5):
1990
- print("Warning: Timeout waiting for server to start, but continuing anyway")
1991
-
1992
- callback_server_running = True
1993
-
1994
- # Set a timer to shutdown the server after CALLBACK_SERVER_TIMEOUT seconds
1995
- if server_shutdown_timer is not None:
1996
- server_shutdown_timer.cancel()
1997
-
1998
- server_shutdown_timer = threading.Timer(CALLBACK_SERVER_TIMEOUT, shutdown_callback_server)
1999
- server_shutdown_timer.daemon = True
2000
- server_shutdown_timer.start()
2001
- print(f"Server will automatically shut down after {CALLBACK_SERVER_TIMEOUT} seconds of inactivity")
2002
-
2003
- # Verify the server is actually accepting connections
2004
- try:
2005
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
2006
- s.settimeout(2)
2007
- s.connect(('localhost', port))
2008
- print(f"Confirmed server is accepting connections on port {port}")
2009
- except Exception as e:
2010
- print(f"Warning: Could not verify server connection: {e}")
2011
-
2012
- return port
2013
-
2014
- except Exception as e:
2015
- print(f"Error starting callback server: {e}")
2016
- # Try again with a different port in case of bind issues
2017
- if "address already in use" in str(e).lower():
2018
- print("Port may be in use, trying a different port...")
2019
- return start_callback_server() # Recursive call with a new port
2020
- raise e
2021
-
2022
- def login_cli():
2023
- """
2024
- Command line entry point for just the login function.
2025
- """
2026
- # Set up command line arguments
2027
- parser = argparse.ArgumentParser(description="Meta Ads Login Utility")
2028
- parser.add_argument("--app-id", type=str, help="Meta App ID (Client ID) for authentication")
2029
- parser.add_argument("--force-login", action="store_true", help="Force a new login even if a token exists")
2030
-
2031
- args = parser.parse_args()
2032
-
2033
- # Update app ID if provided via CLI
2034
- if args.app_id:
2035
- meta_config.set_app_id(args.app_id)
2036
- else:
2037
- # Use existing config or environment variable
2038
- if not meta_config.is_configured():
2039
- print("Error: No Meta App ID provided. Please provide using --app-id or META_APP_ID environment variable.")
2040
- return 1
2041
-
2042
- # Update auth_manager with app ID from config
2043
- auth_manager.app_id = meta_config.get_app_id()
2044
-
2045
- if args.force_login:
2046
- # Clear existing token
2047
- auth_manager.clear_token()
2048
-
2049
- # Perform login
2050
- login()
2051
-
2052
- return 0
2053
-
2054
- def main():
2055
- """
2056
- Main entry point for the Meta Ads MCP Server.
2057
- This function handles command line arguments and runs the server.
2058
- """
2059
- # Set up command line arguments
2060
- parser = argparse.ArgumentParser(description="Meta Ads MCP Server")
2061
- parser.add_argument("--login", action="store_true", help="Authenticate with Meta and store the token")
2062
- parser.add_argument("--app-id", type=str, help="Meta App ID (Client ID) for authentication")
2063
-
2064
- args = parser.parse_args()
2065
-
2066
- # Update app ID if provided via CLI (highest priority)
2067
- if args.app_id:
2068
- meta_config.set_app_id(args.app_id)
2069
-
2070
- # Ensure auth_manager has the current app ID
2071
- app_id = get_current_app_id()
2072
- if app_id:
2073
- auth_manager.app_id = app_id
2074
- else:
2075
- print("Warning: No Meta App ID provided. Authentication will fail.")
2076
-
2077
- # Handle login command
2078
- if args.login:
2079
- if not meta_config.is_configured():
2080
- print("Error: Cannot login without a Meta App ID. Please provide using --app-id or META_APP_ID environment variable.")
2081
- return 1
2082
- login()
2083
- else:
2084
- # Initialize and run the server
2085
- mcp_server.run(transport='stdio')
2086
-
2087
- return 0
2088
-
2089
- # Modify the if __name__ block to use the main function
2090
- if __name__ == "__main__":
2091
- main()