meta-ads-mcp 0.3.6__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,2087 +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 = "") -> 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
- """
780
- # If no account ID is specified, try to get the first one for the user
781
- if not account_id:
782
- accounts_json = await get_ad_accounts(access_token, limit=1)
783
- accounts_data = json.loads(accounts_json)
784
-
785
- if "data" in accounts_data and accounts_data["data"]:
786
- account_id = accounts_data["data"][0]["id"]
787
- else:
788
- return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
789
-
790
- endpoint = f"{account_id}/campaigns"
791
- params = {
792
- "fields": "id,name,objective,status,daily_budget,lifetime_budget,buying_type,start_time,stop_time,created_time,updated_time,bid_strategy",
793
- "limit": limit
794
- }
795
-
796
- if status_filter:
797
- params["effective_status"] = [status_filter]
798
-
799
- data = await make_api_request(endpoint, access_token, params)
800
-
801
- return json.dumps(data, indent=2)
802
-
803
- @mcp_server.tool()
804
- @meta_api_tool
805
- async def get_campaign_details(access_token: str = None, campaign_id: str = None) -> str:
806
- """
807
- Get detailed information about a specific campaign.
808
-
809
- Args:
810
- access_token: Meta API access token (optional - will use cached token if not provided)
811
- campaign_id: Meta Ads campaign ID
812
- """
813
- if not campaign_id:
814
- return json.dumps({"error": "No campaign ID provided"}, indent=2)
815
-
816
- endpoint = f"{campaign_id}"
817
- params = {
818
- "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"
819
- }
820
-
821
- data = await make_api_request(endpoint, access_token, params)
822
-
823
- return json.dumps(data, indent=2)
824
-
825
- @mcp_server.tool()
826
- @meta_api_tool
827
- async def create_campaign(
828
- access_token: str = None,
829
- account_id: str = None,
830
- name: str = None,
831
- objective: str = None,
832
- status: str = "PAUSED",
833
- special_ad_categories: List[str] = None,
834
- daily_budget: Optional[int] = None,
835
- lifetime_budget: Optional[int] = None
836
- ) -> str:
837
- """
838
- Create a new campaign in a Meta Ads account.
839
-
840
- Args:
841
- access_token: Meta API access token (optional - will use cached token if not provided)
842
- account_id: Meta Ads account ID (format: act_XXXXXXXXX)
843
- name: Campaign name
844
- objective: Campaign objective. enum{BRAND_AWARENESS, LEAD_GENERATION, LINK_CLICKS, CONVERSIONS, OUTCOME_TRAFFIC, etc.}.
845
- status: Initial campaign status (default: PAUSED)
846
- special_ad_categories: List of special ad categories if applicable
847
- daily_budget: Daily budget in account currency (in cents)
848
- lifetime_budget: Lifetime budget in account currency (in cents)
849
- """
850
- # Check required parameters
851
- if not account_id:
852
- return json.dumps({"error": "No account ID provided"}, indent=2)
853
-
854
- if not name:
855
- return json.dumps({"error": "No campaign name provided"}, indent=2)
856
-
857
- if not objective:
858
- return json.dumps({"error": "No campaign objective provided"}, indent=2)
859
-
860
- endpoint = f"{account_id}/campaigns"
861
-
862
- params = {
863
- "name": name,
864
- "objective": objective,
865
- "status": status,
866
- }
867
-
868
- if special_ad_categories:
869
- params["special_ad_categories"] = special_ad_categories
870
-
871
- if daily_budget:
872
- params["daily_budget"] = daily_budget
873
-
874
- if lifetime_budget:
875
- params["lifetime_budget"] = lifetime_budget
876
-
877
- data = await make_api_request(endpoint, access_token, params, method="POST")
878
-
879
- return json.dumps(data, indent=2)
880
-
881
- #
882
- # Ad Set Endpoints
883
- #
884
-
885
- @mcp_server.tool()
886
- @meta_api_tool
887
- async def get_adsets(access_token: str = None, account_id: str = None, limit: int = 10, campaign_id: str = "") -> str:
888
- """
889
- Get ad sets for a Meta Ads account with optional filtering by campaign.
890
-
891
- Args:
892
- access_token: Meta API access token (optional - will use cached token if not provided)
893
- account_id: Meta Ads account ID (format: act_XXXXXXXXX)
894
- limit: Maximum number of ad sets to return (default: 10)
895
- campaign_id: Optional campaign ID to filter by
896
- """
897
- # If no account ID is specified, try to get the first one for the user
898
- if not account_id:
899
- accounts_json = await get_ad_accounts(access_token, limit=1)
900
- accounts_data = json.loads(accounts_json)
901
-
902
- if "data" in accounts_data and accounts_data["data"]:
903
- account_id = accounts_data["data"][0]["id"]
904
- else:
905
- return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
906
-
907
- endpoint = f"{account_id}/adsets"
908
- params = {
909
- "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",
910
- "limit": limit
911
- }
912
-
913
- if campaign_id:
914
- params["campaign_id"] = campaign_id
915
-
916
- data = await make_api_request(endpoint, access_token, params)
917
-
918
- return json.dumps(data, indent=2)
919
-
920
- @mcp_server.tool()
921
- @meta_api_tool
922
- async def get_adset_details(access_token: str = None, adset_id: str = None) -> str:
923
- """
924
- Get detailed information about a specific ad set.
925
-
926
- Args:
927
- access_token: Meta API access token (optional - will use cached token if not provided)
928
- adset_id: Meta Ads ad set ID
929
- """
930
- if not adset_id:
931
- return json.dumps({"error": "No ad set ID provided"}, indent=2)
932
-
933
- endpoint = f"{adset_id}"
934
- params = {
935
- "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"
936
- }
937
-
938
- data = await make_api_request(endpoint, access_token, params)
939
-
940
- return json.dumps(data, indent=2)
941
-
942
- #
943
- # Ad Endpoints
944
- #
945
-
946
- @mcp_server.tool()
947
- @meta_api_tool
948
- async def get_ads(
949
- access_token: str = None,
950
- account_id: str = None,
951
- limit: int = 10,
952
- campaign_id: str = "",
953
- adset_id: str = ""
954
- ) -> str:
955
- """
956
- Get ads for a Meta Ads account with optional filtering.
957
-
958
- Args:
959
- access_token: Meta API access token (optional - will use cached token if not provided)
960
- account_id: Meta Ads account ID (format: act_XXXXXXXXX)
961
- limit: Maximum number of ads to return (default: 10)
962
- campaign_id: Optional campaign ID to filter by
963
- adset_id: Optional ad set ID to filter by
964
- """
965
- # If no account ID is specified, try to get the first one for the user
966
- if not account_id:
967
- accounts_json = await get_ad_accounts(access_token=access_token, limit=1)
968
- accounts_data = json.loads(accounts_json)
969
-
970
- if "data" in accounts_data and accounts_data["data"]:
971
- account_id = accounts_data["data"][0]["id"]
972
- else:
973
- return json.dumps({"error": "No account ID specified and no accounts found for user"}, indent=2)
974
-
975
- endpoint = f"{account_id}/ads"
976
- params = {
977
- "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
978
- "limit": limit
979
- }
980
-
981
- if campaign_id:
982
- params["campaign_id"] = campaign_id
983
-
984
- if adset_id:
985
- params["adset_id"] = adset_id
986
-
987
- data = await make_api_request(endpoint, access_token, params)
988
-
989
- return json.dumps(data, indent=2)
990
-
991
- @mcp_server.tool()
992
- @meta_api_tool
993
- async def get_ad_details(access_token: str = None, ad_id: str = None) -> str:
994
- """
995
- Get detailed information about a specific ad.
996
-
997
- Args:
998
- access_token: Meta API access token (optional - will use cached token if not provided)
999
- ad_id: Meta Ads ad ID
1000
- """
1001
- if not ad_id:
1002
- return json.dumps({"error": "No ad ID provided"}, indent=2)
1003
-
1004
- endpoint = f"{ad_id}"
1005
- params = {
1006
- "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs,preview_shareable_link"
1007
- }
1008
-
1009
- data = await make_api_request(endpoint, access_token, params)
1010
-
1011
- return json.dumps(data, indent=2)
1012
-
1013
- @mcp_server.tool()
1014
- @meta_api_tool
1015
- async def get_ad_creatives(access_token: str = None, ad_id: str = None) -> str:
1016
- """
1017
- Get creative details for a specific ad. Best if combined with get_ad_image to get the full image.
1018
-
1019
- Args:
1020
- access_token: Meta API access token (optional - will use cached token if not provided)
1021
- ad_id: Meta Ads ad ID
1022
- """
1023
- if not ad_id:
1024
- return json.dumps({"error": "No ad ID provided"}, indent=2)
1025
-
1026
- # First, get the creative ID from the ad
1027
- endpoint = f"{ad_id}"
1028
- params = {
1029
- "fields": "creative"
1030
- }
1031
-
1032
- ad_data = await make_api_request(endpoint, access_token, params)
1033
-
1034
- if "error" in ad_data:
1035
- return json.dumps(ad_data, indent=2)
1036
-
1037
- if "creative" not in ad_data:
1038
- return json.dumps({"error": "No creative found for this ad"}, indent=2)
1039
-
1040
- creative_id = ad_data.get("creative", {}).get("id")
1041
- if not creative_id:
1042
- return json.dumps({"error": "Creative ID not found", "ad_data": ad_data}, indent=2)
1043
-
1044
- # Now get the creative details with essential fields
1045
- creative_endpoint = f"{creative_id}"
1046
- creative_params = {
1047
- "fields": "id,name,title,body,image_url,object_story_spec,url_tags,link_url,thumbnail_url,image_hash,asset_feed_spec,object_type"
1048
- }
1049
-
1050
- creative_data = await make_api_request(creative_endpoint, access_token, creative_params)
1051
-
1052
- # Try to get full-size images in different ways:
1053
-
1054
- # 1. First approach: Get ad images directly using the adimages endpoint
1055
- if "image_hash" in creative_data:
1056
- image_hash = creative_data.get("image_hash")
1057
- image_endpoint = f"act_{ad_data.get('account_id', '')}/adimages"
1058
- image_params = {
1059
- "hashes": [image_hash]
1060
- }
1061
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1062
- if "data" in image_data and len(image_data["data"]) > 0:
1063
- creative_data["full_image_url"] = image_data["data"][0].get("url")
1064
-
1065
- # 2. For creatives with object_story_spec
1066
- if "object_story_spec" in creative_data:
1067
- spec = creative_data.get("object_story_spec", {})
1068
-
1069
- # For link ads
1070
- if "link_data" in spec:
1071
- link_data = spec.get("link_data", {})
1072
- # If there's an explicit image_url, use it
1073
- if "image_url" in link_data:
1074
- creative_data["full_image_url"] = link_data.get("image_url")
1075
- # If there's an image_hash, try to get the full image
1076
- elif "image_hash" in link_data:
1077
- image_hash = link_data.get("image_hash")
1078
- account_id = ad_data.get('account_id', '')
1079
- if not account_id:
1080
- # Try to get account ID from ad ID
1081
- ad_details_endpoint = f"{ad_id}"
1082
- ad_details_params = {
1083
- "fields": "account_id"
1084
- }
1085
- ad_details = await make_api_request(ad_details_endpoint, access_token, ad_details_params)
1086
- account_id = ad_details.get('account_id', '')
1087
-
1088
- if account_id:
1089
- image_endpoint = f"act_{account_id}/adimages"
1090
- image_params = {
1091
- "hashes": [image_hash]
1092
- }
1093
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1094
- if "data" in image_data and len(image_data["data"]) > 0:
1095
- creative_data["full_image_url"] = image_data["data"][0].get("url")
1096
-
1097
- # For photo ads
1098
- if "photo_data" in spec:
1099
- photo_data = spec.get("photo_data", {})
1100
- if "image_hash" in photo_data:
1101
- image_hash = photo_data.get("image_hash")
1102
- account_id = ad_data.get('account_id', '')
1103
- if not account_id:
1104
- # Try to get account ID from ad ID
1105
- ad_details_endpoint = f"{ad_id}"
1106
- ad_details_params = {
1107
- "fields": "account_id"
1108
- }
1109
- ad_details = await make_api_request(ad_details_endpoint, access_token, ad_details_params)
1110
- account_id = ad_details.get('account_id', '')
1111
-
1112
- if account_id:
1113
- image_endpoint = f"act_{account_id}/adimages"
1114
- image_params = {
1115
- "hashes": [image_hash]
1116
- }
1117
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1118
- if "data" in image_data and len(image_data["data"]) > 0:
1119
- creative_data["full_image_url"] = image_data["data"][0].get("url")
1120
-
1121
- # 3. If there's an asset_feed_spec, try to get images from there
1122
- if "asset_feed_spec" in creative_data and "images" in creative_data["asset_feed_spec"]:
1123
- images = creative_data["asset_feed_spec"]["images"]
1124
- if images and len(images) > 0 and "hash" in images[0]:
1125
- image_hash = images[0]["hash"]
1126
- account_id = ad_data.get('account_id', '')
1127
- if not account_id:
1128
- # Try to get account ID
1129
- ad_details_endpoint = f"{ad_id}"
1130
- ad_details_params = {
1131
- "fields": "account_id"
1132
- }
1133
- ad_details = await make_api_request(ad_details_endpoint, access_token, ad_details_params)
1134
- account_id = ad_details.get('account_id', '')
1135
-
1136
- if account_id:
1137
- image_endpoint = f"act_{account_id}/adimages"
1138
- image_params = {
1139
- "hashes": [image_hash]
1140
- }
1141
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1142
- if "data" in image_data and len(image_data["data"]) > 0:
1143
- creative_data["full_image_url"] = image_data["data"][0].get("url")
1144
-
1145
- # If we have a thumbnail_url but no full_image_url, let's attempt to convert the thumbnail URL to full size
1146
- if "thumbnail_url" in creative_data and "full_image_url" not in creative_data:
1147
- thumbnail_url = creative_data["thumbnail_url"]
1148
- # Try to convert the URL to get higher resolution by removing size parameters
1149
- if "p64x64" in thumbnail_url:
1150
- full_url = thumbnail_url.replace("p64x64", "p1080x1080")
1151
- creative_data["full_image_url"] = full_url
1152
- elif "dst-emg0" in thumbnail_url:
1153
- # Remove the dst-emg0 parameter that seems to reduce size
1154
- full_url = thumbnail_url.replace("dst-emg0_", "")
1155
- creative_data["full_image_url"] = full_url
1156
-
1157
- # Fallback to using thumbnail or image_url if we still don't have a full image
1158
- if "full_image_url" not in creative_data:
1159
- if "thumbnail_url" in creative_data:
1160
- creative_data["full_image_url"] = creative_data["thumbnail_url"]
1161
- elif "image_url" in creative_data:
1162
- creative_data["full_image_url"] = creative_data["image_url"]
1163
-
1164
- return json.dumps(creative_data, indent=2)
1165
-
1166
- @mcp_server.tool()
1167
- @meta_api_tool
1168
- async def get_ad_image(access_token: str = None, ad_id: str = None) -> Image:
1169
- """
1170
- Get, download, and visualize a Meta ad image in one step. Useful to see the image in the LLM.
1171
-
1172
- Args:
1173
- access_token: Meta API access token (optional - will use cached token if not provided)
1174
- ad_id: Meta Ads ad ID
1175
-
1176
- Returns:
1177
- The ad image ready for direct visual analysis
1178
- """
1179
- if not ad_id:
1180
- return "Error: No ad ID provided"
1181
-
1182
- print(f"Attempting to get and analyze creative image for ad {ad_id}")
1183
-
1184
- # First, get creative and account IDs
1185
- ad_endpoint = f"{ad_id}"
1186
- ad_params = {
1187
- "fields": "creative{id},account_id"
1188
- }
1189
-
1190
- ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
1191
-
1192
- if "error" in ad_data:
1193
- return f"Error: Could not get ad data - {json.dumps(ad_data)}"
1194
-
1195
- # Extract account_id
1196
- account_id = ad_data.get("account_id", "")
1197
- if not account_id:
1198
- return "Error: No account ID found"
1199
-
1200
- # Extract creative ID
1201
- if "creative" not in ad_data:
1202
- return "Error: No creative found for this ad"
1203
-
1204
- creative_data = ad_data.get("creative", {})
1205
- creative_id = creative_data.get("id")
1206
- if not creative_id:
1207
- return "Error: No creative ID found"
1208
-
1209
- # Get creative details to find image hash
1210
- creative_endpoint = f"{creative_id}"
1211
- creative_params = {
1212
- "fields": "id,name,image_hash,asset_feed_spec"
1213
- }
1214
-
1215
- creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
1216
-
1217
- # Identify image hashes to use from creative
1218
- image_hashes = []
1219
-
1220
- # Check for direct image_hash on creative
1221
- if "image_hash" in creative_details:
1222
- image_hashes.append(creative_details["image_hash"])
1223
-
1224
- # Check asset_feed_spec for image hashes - common in Advantage+ ads
1225
- if "asset_feed_spec" in creative_details and "images" in creative_details["asset_feed_spec"]:
1226
- for image in creative_details["asset_feed_spec"]["images"]:
1227
- if "hash" in image:
1228
- image_hashes.append(image["hash"])
1229
-
1230
- if not image_hashes:
1231
- # If no hashes found, try to extract from the first creative we found in the API
1232
- # Get creative for ad to try to extract hash
1233
- creative_json = await get_ad_creatives(access_token, ad_id)
1234
- creative_data = json.loads(creative_json)
1235
-
1236
- # Try to extract hash from asset_feed_spec
1237
- if "asset_feed_spec" in creative_data and "images" in creative_data["asset_feed_spec"]:
1238
- images = creative_data["asset_feed_spec"]["images"]
1239
- if images and len(images) > 0 and "hash" in images[0]:
1240
- image_hashes.append(images[0]["hash"])
1241
-
1242
- if not image_hashes:
1243
- return "Error: No image hashes found in creative"
1244
-
1245
- print(f"Found image hashes: {image_hashes}")
1246
-
1247
- # Now fetch image data using adimages endpoint with specific format
1248
- image_endpoint = f"act_{account_id}/adimages"
1249
-
1250
- # Format the hashes parameter exactly as in our successful curl test
1251
- hashes_str = f'["{image_hashes[0]}"]' # Format first hash only, as JSON string array
1252
-
1253
- image_params = {
1254
- "fields": "hash,url,width,height,name,status",
1255
- "hashes": hashes_str
1256
- }
1257
-
1258
- print(f"Requesting image data with params: {image_params}")
1259
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1260
-
1261
- if "error" in image_data:
1262
- return f"Error: Failed to get image data - {json.dumps(image_data)}"
1263
-
1264
- if "data" not in image_data or not image_data["data"]:
1265
- return "Error: No image data returned from API"
1266
-
1267
- # Get the first image URL
1268
- first_image = image_data["data"][0]
1269
- image_url = first_image.get("url")
1270
-
1271
- if not image_url:
1272
- return "Error: No valid image URL found"
1273
-
1274
- print(f"Downloading image from URL: {image_url}")
1275
-
1276
- # Download the image
1277
- image_bytes = await download_image(image_url)
1278
-
1279
- if not image_bytes:
1280
- return "Error: Failed to download image"
1281
-
1282
- try:
1283
- # Convert bytes to PIL Image
1284
- img = PILImage.open(io.BytesIO(image_bytes))
1285
-
1286
- # Convert to RGB if needed
1287
- if img.mode != "RGB":
1288
- img = img.convert("RGB")
1289
-
1290
- # Create a byte stream of the image data
1291
- byte_arr = io.BytesIO()
1292
- img.save(byte_arr, format="JPEG")
1293
- img_bytes = byte_arr.getvalue()
1294
-
1295
- # Return as an Image object that LLM can directly analyze
1296
- return Image(data=img_bytes, format="jpeg")
1297
-
1298
- except Exception as e:
1299
- return f"Error processing image: {str(e)}"
1300
-
1301
- # Resource Handling
1302
- @mcp_server.resource(uri="meta-ads://resources")
1303
- async def list_resources() -> Dict[str, Any]:
1304
- """List all available resources (like ad creative images)"""
1305
- resources = []
1306
-
1307
- # Add all ad creative images as resources
1308
- for resource_id, image_info in ad_creative_images.items():
1309
- resources.append({
1310
- "uri": f"meta-ads://images/{resource_id}",
1311
- "mimeType": image_info["mime_type"],
1312
- "name": image_info["name"]
1313
- })
1314
-
1315
- return {"resources": resources}
1316
-
1317
- @mcp_server.resource(uri="meta-ads://images/{resource_id}")
1318
- async def get_resource(resource_id: str) -> Dict[str, Any]:
1319
- """Get a specific resource by URI"""
1320
- if resource_id in ad_creative_images:
1321
- image_info = ad_creative_images[resource_id]
1322
- return {
1323
- "data": base64.b64encode(image_info["data"]).decode("utf-8"),
1324
- "mimeType": image_info["mime_type"]
1325
- }
1326
-
1327
- # Resource not found
1328
- return {"error": f"Resource not found: {resource_id}"}
1329
-
1330
- #
1331
- # Insights Endpoints
1332
- #
1333
-
1334
- @mcp_server.tool()
1335
- @meta_api_tool
1336
- async def get_insights(
1337
- access_token: str = None,
1338
- object_id: str = None,
1339
- time_range: str = "maximum",
1340
- breakdown: str = "",
1341
- level: str = "ad"
1342
- ) -> str:
1343
- """
1344
- Get performance insights for a campaign, ad set, ad or account.
1345
-
1346
- Args:
1347
- access_token: Meta API access token (optional - will use cached token if not provided)
1348
- object_id: ID of the campaign, ad set, ad or account
1349
- 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)
1350
- breakdown: Optional breakdown dimension (e.g., age, gender, country)
1351
- level: Level of aggregation (ad, adset, campaign, account)
1352
- """
1353
- # Import logger
1354
- from meta_ads_mcp.core.utils import logger
1355
-
1356
- # Log function call details
1357
- logger.info(f"get_insights called with object_id: {object_id}, time_range: {time_range}, level: {level}")
1358
-
1359
- # Log authentication details
1360
- from meta_ads_mcp.core.auth import meta_config
1361
- app_id = meta_config.get_app_id()
1362
- logger.info(f"App ID from meta_config: {app_id}")
1363
-
1364
- # Validate inputs
1365
- if not object_id:
1366
- logger.error("No object ID provided for get_insights")
1367
- return json.dumps({"error": "Missing Required Parameter", "details": {"message": "No object ID provided"}}, indent=2)
1368
-
1369
- # Check access token (masking for security)
1370
- token_status = "provided" if access_token else "not provided"
1371
- logger.info(f"Access token status: {token_status}")
1372
-
1373
- # Log the specific object ID format for troubleshooting
1374
- if object_id.startswith("act_"):
1375
- logger.info(f"Object ID is an account ID: {object_id}")
1376
- else:
1377
- logger.info(f"Object ID format: {object_id}")
1378
-
1379
- endpoint = f"{object_id}/insights"
1380
- logger.info(f"Using endpoint: {endpoint}")
1381
-
1382
- params = {
1383
- "date_preset": time_range,
1384
- "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",
1385
- "level": level
1386
- }
1387
-
1388
- if breakdown:
1389
- params["breakdowns"] = breakdown
1390
-
1391
- logger.info("Making API request for insights")
1392
- try:
1393
- data = await make_api_request(endpoint, access_token, params)
1394
- logger.info(f"API response received: {'success' if 'error' not in data else 'error'}")
1395
-
1396
- # Check for specific app ID errors and improve error message
1397
- if "error" in data:
1398
- error_details = data.get("details", {}).get("error", {})
1399
- if isinstance(error_details, dict) and error_details.get("code") == 200:
1400
- logger.error(f"Authentication configuration error in response: {error_details.get('message')}")
1401
- return json.dumps({
1402
- "error": "Meta API Configuration Issue",
1403
- "details": {
1404
- "message": "There is an issue with your Meta API configuration",
1405
- "action_required": "Check your META_APP_ID environment variable or re-authenticate",
1406
- "current_app_id": app_id
1407
- }
1408
- }, indent=2)
1409
- except Exception as e:
1410
- logger.error(f"Exception during get_insights API call: {str(e)}")
1411
- data = {"error": str(e)}
1412
-
1413
- return json.dumps(data, indent=2)
1414
-
1415
- @mcp_server.tool()
1416
- @meta_api_tool
1417
- async def debug_image_download(url="", ad_id="", access_token=None):
1418
- """Debug image download issues and report detailed diagnostics."""
1419
- results = {}
1420
-
1421
- if url:
1422
- results["image_url"] = url
1423
- else:
1424
- # If no URL provided but ad_id is, get URL from ad creative
1425
- if ad_id:
1426
- print(f"Getting image URL from ad creative for ad {ad_id}")
1427
- # Get the creative details
1428
- creative_json = await get_ad_creatives(access_token=access_token, ad_id=ad_id)
1429
- creative_data = json.loads(creative_json)
1430
- results["creative_data"] = creative_data
1431
-
1432
- # Look for image URL in the creative
1433
- if "full_image_url" in creative_data:
1434
- url = creative_data.get("full_image_url")
1435
- elif "thumbnail_url" in creative_data:
1436
- url = creative_data.get("thumbnail_url")
1437
-
1438
- if not url:
1439
- return json.dumps({
1440
- "error": "No image URL provided or found in ad creative",
1441
- "results": results
1442
- }, indent=2)
1443
-
1444
- results["image_url"] = url
1445
-
1446
- # Method 1: Basic download
1447
- method_result = {
1448
- "method": "Basic download with standard headers",
1449
- "success": False
1450
- }
1451
- results["diagnostics"] = {"methods_tried": [method_result]}
1452
-
1453
- try:
1454
- headers = {
1455
- "User-Agent": USER_AGENT
1456
- }
1457
- async with httpx.AsyncClient(follow_redirects=True) as client:
1458
- response = await client.get(url, headers=headers, timeout=30.0)
1459
- method_result["status_code"] = response.status_code
1460
- method_result["headers"] = dict(response.headers)
1461
-
1462
- if response.status_code == 200:
1463
- method_result["success"] = True
1464
- method_result["content_length"] = len(response.content)
1465
- method_result["content_type"] = response.headers.get("content-type")
1466
-
1467
- # Save this successful result
1468
- results["image_data"] = {
1469
- "length": len(response.content),
1470
- "type": response.headers.get("content-type"),
1471
- "base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
1472
- }
1473
- except Exception as e:
1474
- method_result["error"] = str(e)
1475
-
1476
- # Method 2: Browser emulation
1477
- method_result = {
1478
- "method": "Browser emulation with cookies",
1479
- "success": False
1480
- }
1481
- results["diagnostics"]["methods_tried"].append(method_result)
1482
-
1483
- try:
1484
- headers = {
1485
- "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",
1486
- "Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
1487
- "Accept-Language": "en-US,en;q=0.9",
1488
- "Referer": "https://www.facebook.com/",
1489
- "Cookie": "presence=EDvF3EtimeF1697900316EuserFA21B00112233445566AA0EstateFDutF0CEchF_7bCC"
1490
- }
1491
-
1492
- async with httpx.AsyncClient(follow_redirects=True) as client:
1493
- response = await client.get(url, headers=headers, timeout=30.0)
1494
- method_result["status_code"] = response.status_code
1495
- method_result["headers"] = dict(response.headers)
1496
-
1497
- if response.status_code == 200:
1498
- method_result["success"] = True
1499
- method_result["content_length"] = len(response.content)
1500
- method_result["content_type"] = response.headers.get("content-type")
1501
-
1502
- # If first method didn't succeed, save this successful result
1503
- if "image_data" not in results:
1504
- results["image_data"] = {
1505
- "length": len(response.content),
1506
- "type": response.headers.get("content-type"),
1507
- "base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
1508
- }
1509
- except Exception as e:
1510
- method_result["error"] = str(e)
1511
-
1512
- # Method 3: Graph API direct access (if applicable)
1513
- if "fbcdn" in url or "facebook" in url:
1514
- method_result = {
1515
- "method": "Graph API direct access",
1516
- "success": False
1517
- }
1518
- results["diagnostics"]["methods_tried"].append(method_result)
1519
-
1520
- try:
1521
- # Try to reconstruct the attachment ID from URL if possible
1522
- url_parts = url.split("/")
1523
- potential_ids = [part for part in url_parts if part.isdigit() and len(part) > 10]
1524
-
1525
- if ad_id and potential_ids:
1526
- attachment_id = potential_ids[0]
1527
- endpoint = f"{attachment_id}?fields=url,width,height"
1528
- api_result = await make_api_request(endpoint, access_token)
1529
-
1530
- method_result["api_response"] = api_result
1531
-
1532
- if "url" in api_result:
1533
- graph_url = api_result["url"]
1534
- method_result["graph_url"] = graph_url
1535
-
1536
- # Try to download from this Graph API URL
1537
- async with httpx.AsyncClient() as client:
1538
- response = await client.get(graph_url, timeout=30.0)
1539
-
1540
- method_result["status_code"] = response.status_code
1541
- if response.status_code == 200:
1542
- method_result["success"] = True
1543
- method_result["content_length"] = len(response.content)
1544
-
1545
- # If previous methods didn't succeed, save this successful result
1546
- if "image_data" not in results:
1547
- results["image_data"] = {
1548
- "length": len(response.content),
1549
- "type": response.headers.get("content-type"),
1550
- "base64_sample": base64.b64encode(response.content[:100]).decode("utf-8") + "..." if response.content else None
1551
- }
1552
- except Exception as e:
1553
- method_result["error"] = str(e)
1554
-
1555
- # Generate a recommendation based on what we found
1556
- if "image_data" in results:
1557
- results["recommendation"] = "At least one download method succeeded. Consider implementing the successful method in the main code."
1558
- else:
1559
- # Check if the error appears to be access-related
1560
- access_errors = False
1561
- for method in results["diagnostics"]["methods_tried"]:
1562
- if method.get("status_code") in [401, 403, 503]:
1563
- access_errors = True
1564
-
1565
- if access_errors:
1566
- results["recommendation"] = "Authentication or authorization errors detected. Images may require direct Facebook authentication not possible via API."
1567
- else:
1568
- results["recommendation"] = "Network or other technical errors detected. Check URL expiration or CDN restrictions."
1569
-
1570
- return json.dumps(results, indent=2)
1571
-
1572
- @mcp_server.tool()
1573
- @meta_api_tool
1574
- async def save_ad_image_via_api(access_token: str = None, ad_id: str = None) -> str:
1575
- """
1576
- Try to save an ad image by using the Marketing API's attachment endpoints.
1577
- This is an alternative approach when direct image download fails.
1578
-
1579
- Args:
1580
- access_token: Meta API access token (optional - will use cached token if not provided)
1581
- ad_id: Meta Ads ad ID
1582
- """
1583
- if not ad_id:
1584
- return json.dumps({"error": "No ad ID provided"}, indent=2)
1585
-
1586
- print(f"Attempting to save image for ad {ad_id} using API methods")
1587
-
1588
- # First, get creative ID and account ID
1589
- ad_endpoint = f"{ad_id}"
1590
- ad_params = {
1591
- "fields": "creative{id},account_id"
1592
- }
1593
-
1594
- ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
1595
-
1596
- if "error" in ad_data:
1597
- return json.dumps({"error": f"Could not get ad data - {ad_data['error']}"}, indent=2)
1598
-
1599
- # Extract account_id
1600
- account_id = ad_data.get("account_id", "")
1601
- if not account_id:
1602
- return json.dumps({"error": "No account ID found"}, indent=2)
1603
-
1604
- # Extract creative ID
1605
- if "creative" not in ad_data:
1606
- return json.dumps({"error": "No creative found for this ad"}, indent=2)
1607
-
1608
- creative_data = ad_data.get("creative", {})
1609
- creative_id = creative_data.get("id")
1610
- if not creative_id:
1611
- return json.dumps({"error": "No creative ID found"}, indent=2)
1612
-
1613
- # Get creative details to find image hash
1614
- creative_endpoint = f"{creative_id}"
1615
- creative_params = {
1616
- "fields": "id,name,image_hash,thumbnail_url,image_url,object_story_spec"
1617
- }
1618
-
1619
- creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
1620
-
1621
- if "error" in creative_details:
1622
- return json.dumps({"error": f"Could not get creative details - {creative_details['error']}"}, indent=2)
1623
-
1624
- results = {
1625
- "ad_id": ad_id,
1626
- "creative_id": creative_id,
1627
- "account_id": account_id,
1628
- "creative_details": creative_details
1629
- }
1630
-
1631
- # Try to find image hash
1632
- image_hash = None
1633
-
1634
- # Direct hash on creative
1635
- if "image_hash" in creative_details:
1636
- image_hash = creative_details["image_hash"]
1637
-
1638
- # Look in object_story_spec
1639
- elif "object_story_spec" in creative_details:
1640
- spec = creative_details["object_story_spec"]
1641
-
1642
- # For link ads
1643
- if "link_data" in spec:
1644
- link_data = spec["link_data"]
1645
- if "image_hash" in link_data:
1646
- image_hash = link_data["image_hash"]
1647
-
1648
- # For photo ads
1649
- elif "photo_data" in spec:
1650
- photo_data = spec["photo_data"]
1651
- if "image_hash" in photo_data:
1652
- image_hash = photo_data["image_hash"]
1653
-
1654
- if not image_hash:
1655
- return json.dumps({
1656
- "error": "No image hash found in creative",
1657
- "creative_details": creative_details
1658
- }, indent=2)
1659
-
1660
- # Now get image data from the adimages endpoint
1661
- image_endpoint = f"act_{account_id}/adimages"
1662
- image_params = {
1663
- "hashes": [image_hash]
1664
- }
1665
-
1666
- image_data = await make_api_request(image_endpoint, access_token, image_params)
1667
-
1668
- if "error" in image_data:
1669
- return json.dumps({
1670
- "error": f"Failed to get image data - {image_data['error']}",
1671
- "hash": image_hash
1672
- }, indent=2)
1673
-
1674
- if "data" not in image_data or not image_data["data"]:
1675
- return json.dumps({
1676
- "error": "No image data returned from API",
1677
- "hash": image_hash
1678
- }, indent=2)
1679
-
1680
- # Get the URL from the first image
1681
- first_image = image_data["data"][0]
1682
- image_url = first_image.get("url")
1683
-
1684
- if not image_url:
1685
- return json.dumps({
1686
- "error": "No image URL found in API response",
1687
- "api_response": image_data
1688
- }, indent=2)
1689
-
1690
- # Try to save the image by directly downloading it
1691
- results["image_url"] = image_url
1692
-
1693
- try:
1694
- # Try multiple download methods
1695
- image_bytes = await try_multiple_download_methods(image_url)
1696
-
1697
- if not image_bytes:
1698
- return json.dumps({
1699
- "error": "Failed to download image from URL provided by API",
1700
- "image_url": image_url,
1701
- "suggestion": "Try using the debug_image_download tool for more details"
1702
- }, indent=2)
1703
-
1704
- # Create a resource ID for this image
1705
- resource_id = f"ad_{ad_id}_{int(time.time())}"
1706
-
1707
- # Store the image
1708
- img = PILImage.open(io.BytesIO(image_bytes))
1709
- mime_type = f"image/{img.format.lower()}" if img.format else "image/jpeg"
1710
-
1711
- # Save to our global dictionary
1712
- ad_creative_images[resource_id] = {
1713
- "data": image_bytes,
1714
- "mime_type": mime_type,
1715
- "name": f"Ad {ad_id} Image",
1716
- "width": img.width,
1717
- "height": img.height,
1718
- "format": img.format
1719
- }
1720
-
1721
- # Return the success result with resource info
1722
- return json.dumps({
1723
- "success": True,
1724
- "message": "Successfully saved image",
1725
- "resource_id": resource_id,
1726
- "resource_uri": f"meta-ads://images/{resource_id}",
1727
- "image_details": {
1728
- "width": img.width,
1729
- "height": img.height,
1730
- "format": img.format,
1731
- "size_bytes": len(image_bytes)
1732
- }
1733
- }, indent=2)
1734
-
1735
- except Exception as e:
1736
- return json.dumps({
1737
- "error": f"Error saving image: {str(e)}",
1738
- "image_url": image_url
1739
- }, indent=2)
1740
-
1741
- # Helper function to start the login flow
1742
- def login():
1743
- """
1744
- Start the login flow to authenticate with Meta
1745
- """
1746
- print("Starting Meta Ads authentication flow...")
1747
-
1748
- # Ensure auth_manager has the current app ID from config
1749
- current_app_id = meta_config.get_app_id()
1750
- if not current_app_id:
1751
- print("Error: No Meta App ID available. Authentication will fail.")
1752
- print("Please provide an app ID using --app-id or via META_APP_ID environment variable.")
1753
- return
1754
-
1755
- auth_manager.app_id = current_app_id
1756
- print(f"Using Meta App ID from config: {current_app_id}")
1757
-
1758
- try:
1759
- # Start the callback server first
1760
- port = start_callback_server()
1761
-
1762
- # Get the auth URL and open the browser
1763
- auth_url = auth_manager.get_auth_url()
1764
- print(f"Opening browser with URL: {auth_url}")
1765
- webbrowser.open(auth_url)
1766
-
1767
- # Wait for token to be received
1768
- print("Waiting for authentication to complete...")
1769
- max_wait = 300 # 5 minutes
1770
- wait_interval = 2 # 2 seconds
1771
-
1772
- for _ in range(max_wait // wait_interval):
1773
- if token_container["token"]:
1774
- token = token_container["token"]
1775
- print("Authentication successful!")
1776
- # Verify token works by getting basic user info
1777
- try:
1778
- result = asyncio.run(make_api_request("me", token, {}))
1779
- print(f"Authenticated as: {result.get('name', 'Unknown')} (ID: {result.get('id', 'Unknown')})")
1780
- return
1781
- except Exception as e:
1782
- print(f"Warning: Could not verify token: {e}")
1783
- return
1784
- time.sleep(wait_interval)
1785
-
1786
- print("Authentication timed out. Please try again.")
1787
- except Exception as e:
1788
- print(f"Error during authentication: {e}")
1789
- print(f"Direct authentication URL: {auth_manager.get_auth_url()}")
1790
- print("You can manually open this URL in your browser to complete authentication.")
1791
-
1792
- async def download_image(url: str) -> Optional[bytes]:
1793
- """
1794
- Download an image from a URL.
1795
-
1796
- Args:
1797
- url: Image URL
1798
-
1799
- Returns:
1800
- Image data as bytes if successful, None otherwise
1801
- """
1802
- try:
1803
- print(f"Attempting to download image from URL: {url}")
1804
-
1805
- # Use minimal headers like curl does
1806
- headers = {
1807
- "User-Agent": "curl/8.4.0",
1808
- "Accept": "*/*"
1809
- }
1810
-
1811
- # Ensure the URL is properly escaped
1812
- # But don't modify the already encoded parameters
1813
-
1814
- async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
1815
- # Simple GET request just like curl
1816
- response = await client.get(url, headers=headers)
1817
-
1818
- # Check response
1819
- if response.status_code == 200:
1820
- print(f"Successfully downloaded image: {len(response.content)} bytes")
1821
- return response.content
1822
- else:
1823
- print(f"Failed to download image: HTTP {response.status_code}")
1824
- return None
1825
-
1826
- except httpx.HTTPStatusError as e:
1827
- print(f"HTTP Error when downloading image: {e}")
1828
- return None
1829
- except httpx.RequestError as e:
1830
- print(f"Request Error when downloading image: {e}")
1831
- return None
1832
- except Exception as e:
1833
- print(f"Unexpected error downloading image: {e}")
1834
- return None
1835
-
1836
- # Add a new helper function to try different download methods
1837
- async def try_multiple_download_methods(url: str) -> Optional[bytes]:
1838
- """
1839
- Try multiple methods to download an image, with different approaches for Meta CDN.
1840
-
1841
- Args:
1842
- url: Image URL
1843
-
1844
- Returns:
1845
- Image data as bytes if successful, None otherwise
1846
- """
1847
- # Method 1: Direct download with custom headers
1848
- image_data = await download_image(url)
1849
- if image_data:
1850
- return image_data
1851
-
1852
- print("Direct download failed, trying alternative methods...")
1853
-
1854
- # Method 2: Try adding Facebook cookie simulation
1855
- try:
1856
- headers = {
1857
- "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",
1858
- "Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
1859
- "Cookie": "presence=EDvF3EtimeF1697900316EuserFA21B00112233445566AA0EstateFDutF0CEchF_7bCC" # Fake cookie
1860
- }
1861
-
1862
- async with httpx.AsyncClient(follow_redirects=True) as client:
1863
- response = await client.get(url, headers=headers, timeout=30.0)
1864
- response.raise_for_status()
1865
- print(f"Method 2 succeeded with cookie simulation: {len(response.content)} bytes")
1866
- return response.content
1867
- except Exception as e:
1868
- print(f"Method 2 failed: {str(e)}")
1869
-
1870
- # Method 3: Try with session that keeps redirects and cookies
1871
- try:
1872
- async with httpx.AsyncClient(follow_redirects=True) as client:
1873
- # First visit Facebook to get cookies
1874
- await client.get("https://www.facebook.com/", timeout=30.0)
1875
- # Then try the image URL
1876
- response = await client.get(url, timeout=30.0)
1877
- response.raise_for_status()
1878
- print(f"Method 3 succeeded with Facebook session: {len(response.content)} bytes")
1879
- return response.content
1880
- except Exception as e:
1881
- print(f"Method 3 failed: {str(e)}")
1882
-
1883
- return None
1884
-
1885
- def shutdown_callback_server():
1886
- """Shutdown the callback server if it's running"""
1887
- global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
1888
-
1889
- with callback_server_lock:
1890
- if not callback_server_running:
1891
- print("Callback server is not running")
1892
- return
1893
-
1894
- if server_shutdown_timer is not None:
1895
- server_shutdown_timer.cancel()
1896
- server_shutdown_timer = None
1897
-
1898
- print(f"Shutting down callback server on port {callback_server_port}")
1899
-
1900
- # Shutdown the server if it exists
1901
- if callback_server_instance:
1902
- try:
1903
- callback_server_instance.shutdown()
1904
- callback_server_instance = None
1905
- callback_server_running = False
1906
- print("Callback server has been shut down")
1907
- except Exception as e:
1908
- print(f"Error shutting down callback server: {e}")
1909
- else:
1910
- print("No server instance to shut down")
1911
-
1912
- def start_callback_server():
1913
- """Start the callback server if it's not already running"""
1914
- global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
1915
-
1916
- with callback_server_lock:
1917
- if callback_server_running:
1918
- print(f"Callback server already running on port {callback_server_port}")
1919
-
1920
- # Reset the shutdown timer if one exists
1921
- if server_shutdown_timer is not None:
1922
- server_shutdown_timer.cancel()
1923
-
1924
- server_shutdown_timer = threading.Timer(CALLBACK_SERVER_TIMEOUT, shutdown_callback_server)
1925
- server_shutdown_timer.daemon = True
1926
- server_shutdown_timer.start()
1927
- print(f"Reset server shutdown timer to {CALLBACK_SERVER_TIMEOUT} seconds")
1928
-
1929
- return callback_server_port
1930
-
1931
- # Find an available port
1932
- port = 8888
1933
- max_attempts = 10
1934
- for attempt in range(max_attempts):
1935
- try:
1936
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1937
- s.bind(('localhost', port))
1938
- break
1939
- except OSError:
1940
- port += 1
1941
- if attempt == max_attempts - 1:
1942
- raise Exception(f"Could not find an available port after {max_attempts} attempts")
1943
-
1944
- # Update auth manager's redirect URI with new port
1945
- auth_manager.redirect_uri = f"http://localhost:{port}/callback"
1946
-
1947
- # Always make sure auth_manager has the current app ID from config
1948
- current_app_id = meta_config.get_app_id()
1949
- if current_app_id:
1950
- auth_manager.app_id = current_app_id
1951
-
1952
- callback_server_port = port
1953
-
1954
- try:
1955
- # Get the CallbackHandler class from global scope
1956
- handler_class = globals()['CallbackHandler']
1957
-
1958
- # Create and start server in a daemon thread
1959
- server = HTTPServer(('localhost', port), handler_class)
1960
- callback_server_instance = server
1961
- print(f"Callback server starting on port {port}")
1962
-
1963
- # Create a simple flag to signal when the server is ready
1964
- server_ready = threading.Event()
1965
-
1966
- def server_thread():
1967
- try:
1968
- # Signal that the server thread has started
1969
- server_ready.set()
1970
- print(f"Callback server is now ready on port {port}")
1971
- # Start serving HTTP requests
1972
- server.serve_forever()
1973
- except Exception as e:
1974
- print(f"Server error: {e}")
1975
- finally:
1976
- with callback_server_lock:
1977
- global callback_server_running
1978
- callback_server_running = False
1979
-
1980
- callback_server_thread = threading.Thread(target=server_thread)
1981
- callback_server_thread.daemon = True
1982
- callback_server_thread.start()
1983
-
1984
- # Wait for server to be ready (up to 5 seconds)
1985
- if not server_ready.wait(timeout=5):
1986
- print("Warning: Timeout waiting for server to start, but continuing anyway")
1987
-
1988
- callback_server_running = True
1989
-
1990
- # Set a timer to shutdown the server after CALLBACK_SERVER_TIMEOUT seconds
1991
- if server_shutdown_timer is not None:
1992
- server_shutdown_timer.cancel()
1993
-
1994
- server_shutdown_timer = threading.Timer(CALLBACK_SERVER_TIMEOUT, shutdown_callback_server)
1995
- server_shutdown_timer.daemon = True
1996
- server_shutdown_timer.start()
1997
- print(f"Server will automatically shut down after {CALLBACK_SERVER_TIMEOUT} seconds of inactivity")
1998
-
1999
- # Verify the server is actually accepting connections
2000
- try:
2001
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
2002
- s.settimeout(2)
2003
- s.connect(('localhost', port))
2004
- print(f"Confirmed server is accepting connections on port {port}")
2005
- except Exception as e:
2006
- print(f"Warning: Could not verify server connection: {e}")
2007
-
2008
- return port
2009
-
2010
- except Exception as e:
2011
- print(f"Error starting callback server: {e}")
2012
- # Try again with a different port in case of bind issues
2013
- if "address already in use" in str(e).lower():
2014
- print("Port may be in use, trying a different port...")
2015
- return start_callback_server() # Recursive call with a new port
2016
- raise e
2017
-
2018
- def login_cli():
2019
- """
2020
- Command line entry point for just the login function.
2021
- """
2022
- # Set up command line arguments
2023
- parser = argparse.ArgumentParser(description="Meta Ads Login Utility")
2024
- parser.add_argument("--app-id", type=str, help="Meta App ID (Client ID) for authentication")
2025
- parser.add_argument("--force-login", action="store_true", help="Force a new login even if a token exists")
2026
-
2027
- args = parser.parse_args()
2028
-
2029
- # Update app ID if provided via CLI
2030
- if args.app_id:
2031
- meta_config.set_app_id(args.app_id)
2032
- else:
2033
- # Use existing config or environment variable
2034
- if not meta_config.is_configured():
2035
- print("Error: No Meta App ID provided. Please provide using --app-id or META_APP_ID environment variable.")
2036
- return 1
2037
-
2038
- # Update auth_manager with app ID from config
2039
- auth_manager.app_id = meta_config.get_app_id()
2040
-
2041
- if args.force_login:
2042
- # Clear existing token
2043
- auth_manager.clear_token()
2044
-
2045
- # Perform login
2046
- login()
2047
-
2048
- return 0
2049
-
2050
- def main():
2051
- """
2052
- Main entry point for the Meta Ads MCP Server.
2053
- This function handles command line arguments and runs the server.
2054
- """
2055
- # Set up command line arguments
2056
- parser = argparse.ArgumentParser(description="Meta Ads MCP Server")
2057
- parser.add_argument("--login", action="store_true", help="Authenticate with Meta and store the token")
2058
- parser.add_argument("--app-id", type=str, help="Meta App ID (Client ID) for authentication")
2059
-
2060
- args = parser.parse_args()
2061
-
2062
- # Update app ID if provided via CLI (highest priority)
2063
- if args.app_id:
2064
- meta_config.set_app_id(args.app_id)
2065
-
2066
- # Ensure auth_manager has the current app ID
2067
- app_id = get_current_app_id()
2068
- if app_id:
2069
- auth_manager.app_id = app_id
2070
- else:
2071
- print("Warning: No Meta App ID provided. Authentication will fail.")
2072
-
2073
- # Handle login command
2074
- if args.login:
2075
- if not meta_config.is_configured():
2076
- print("Error: Cannot login without a Meta App ID. Please provide using --app-id or META_APP_ID environment variable.")
2077
- return 1
2078
- login()
2079
- else:
2080
- # Initialize and run the server
2081
- mcp_server.run(transport='stdio')
2082
-
2083
- return 0
2084
-
2085
- # Modify the if __name__ block to use the main function
2086
- if __name__ == "__main__":
2087
- main()