meta-ads-mcp 0.1.0__py3-none-any.whl

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