meta-ads-mcp 0.2.1__py3-none-any.whl → 0.2.2__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,211 @@
1
+ """Core API functionality for Meta Ads API."""
2
+
3
+ from typing import Any, Dict, Optional, Callable
4
+ import json
5
+ import httpx
6
+ import asyncio
7
+ import functools
8
+ from .auth import needs_authentication, get_current_access_token, auth_manager, start_callback_server
9
+
10
+ # Constants
11
+ META_GRAPH_API_VERSION = "v20.0"
12
+ META_GRAPH_API_BASE = f"https://graph.facebook.com/{META_GRAPH_API_VERSION}"
13
+ USER_AGENT = "meta-ads-mcp/1.0"
14
+
15
+
16
+ class GraphAPIError(Exception):
17
+ """Exception raised for errors from the Graph API."""
18
+ def __init__(self, error_data: Dict[str, Any]):
19
+ self.error_data = error_data
20
+ self.message = error_data.get('message', 'Unknown Graph API error')
21
+ super().__init__(self.message)
22
+
23
+ # Check if this is an auth error
24
+ if "code" in error_data and error_data["code"] in [190, 102, 4]:
25
+ # Common auth error codes
26
+ auth_manager.invalidate_token()
27
+
28
+
29
+ async def make_api_request(
30
+ endpoint: str,
31
+ access_token: str,
32
+ params: Optional[Dict[str, Any]] = None,
33
+ method: str = "GET"
34
+ ) -> Dict[str, Any]:
35
+ """
36
+ Make a request to the Meta Graph API.
37
+
38
+ Args:
39
+ endpoint: API endpoint path (without base URL)
40
+ access_token: Meta API access token
41
+ params: Additional query parameters
42
+ method: HTTP method (GET, POST, DELETE)
43
+
44
+ Returns:
45
+ API response as a dictionary
46
+ """
47
+ url = f"{META_GRAPH_API_BASE}/{endpoint}"
48
+
49
+ headers = {
50
+ "User-Agent": USER_AGENT,
51
+ }
52
+
53
+ request_params = params or {}
54
+ request_params["access_token"] = access_token
55
+
56
+ # Print the request details (masking the token for security)
57
+ masked_params = {k: "***TOKEN***" if k == "access_token" else v for k, v in request_params.items()}
58
+ print(f"Making {method} request to: {url}")
59
+ print(f"Params: {masked_params}")
60
+
61
+ async with httpx.AsyncClient() as client:
62
+ try:
63
+ if method == "GET":
64
+ response = await client.get(url, params=request_params, headers=headers, timeout=30.0)
65
+ elif method == "POST":
66
+ response = await client.post(url, json=request_params, headers=headers, timeout=30.0)
67
+ elif method == "DELETE":
68
+ response = await client.delete(url, params=request_params, headers=headers, timeout=30.0)
69
+ else:
70
+ raise ValueError(f"Unsupported HTTP method: {method}")
71
+
72
+ response.raise_for_status()
73
+ return response.json()
74
+
75
+ except httpx.HTTPStatusError as e:
76
+ error_info = {}
77
+ try:
78
+ error_info = e.response.json()
79
+ except:
80
+ error_info = {"status_code": e.response.status_code, "text": e.response.text}
81
+
82
+ print(f"HTTP Error: {e.response.status_code} - {error_info}")
83
+
84
+ # Check for authentication errors
85
+ if e.response.status_code == 401 or e.response.status_code == 403:
86
+ print("Detected authentication error")
87
+ auth_manager.invalidate_token()
88
+ elif "error" in error_info:
89
+ error_obj = error_info.get("error", {})
90
+ # Check for specific FB API errors related to auth
91
+ if isinstance(error_obj, dict) and error_obj.get("code") in [190, 102, 4, 200, 10]:
92
+ print(f"Detected Facebook API auth error: {error_obj.get('code')}")
93
+ auth_manager.invalidate_token()
94
+
95
+ return {"error": f"HTTP Error: {e.response.status_code}", "details": error_info}
96
+
97
+ except Exception as e:
98
+ print(f"Request Error: {str(e)}")
99
+ return {"error": str(e)}
100
+
101
+
102
+ # Generic wrapper for all Meta API tools
103
+ def meta_api_tool(func):
104
+ """Decorator to handle authentication for all Meta API tools"""
105
+ @functools.wraps(func)
106
+ async def wrapper(*args, **kwargs):
107
+ # Handle various MCP invocation patterns
108
+ if len(args) == 1 and isinstance(args[0], str):
109
+ # If it's a string and looks like JSON, try to parse it
110
+ try:
111
+ parsed = json.loads(args[0]) if args[0] else {}
112
+ if isinstance(parsed, dict):
113
+ # If it has an 'args' key, use that for positional args
114
+ if 'args' in parsed:
115
+ args = (parsed['args'],)
116
+ # If it has a 'kwargs' key, update kwargs
117
+ if 'kwargs' in parsed:
118
+ if isinstance(parsed['kwargs'], str):
119
+ try:
120
+ kwargs.update(json.loads(parsed['kwargs']))
121
+ except:
122
+ pass
123
+ elif isinstance(parsed['kwargs'], dict):
124
+ kwargs.update(parsed['kwargs'])
125
+ else:
126
+ # If it's not a dict, treat it as a single positional arg
127
+ args = (args[0],)
128
+ except:
129
+ # If parsing fails, treat it as a single positional arg
130
+ args = (args[0],)
131
+
132
+ # Check if access_token is provided in kwargs
133
+ access_token = kwargs.get('access_token')
134
+
135
+ # If not, try to get it from the auth manager
136
+ if not access_token:
137
+ access_token = await get_current_access_token()
138
+
139
+ # If still no token, we need authentication
140
+ if not access_token:
141
+ global needs_authentication
142
+ needs_authentication = True
143
+
144
+ # Start the callback server
145
+ port = start_callback_server()
146
+
147
+ # Update auth manager's redirect URI with the current port
148
+ auth_manager.redirect_uri = f"http://localhost:{port}/callback"
149
+
150
+ # Generate the authentication URL
151
+ login_url = auth_manager.get_auth_url()
152
+
153
+ # Create a resource response that includes the markdown link format
154
+ response = {
155
+ "error": "Authentication required to use Meta Ads API",
156
+ "login_url": login_url,
157
+ "server_status": f"Callback server running on port {port}",
158
+ "markdown_link": f"[Click here to authenticate with Meta Ads API]({login_url})",
159
+ "message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
160
+ "instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
161
+ "note": "After authenticating, the token will be automatically saved."
162
+ }
163
+
164
+ # Wait a moment to ensure the server is fully started
165
+ await asyncio.sleep(1)
166
+
167
+ return json.dumps(response, indent=2)
168
+
169
+ # Update kwargs with the token
170
+ kwargs['access_token'] = access_token
171
+
172
+ # Call the original function
173
+ try:
174
+ result = await func(*args, **kwargs)
175
+
176
+ # If authentication is needed after the call (e.g., token was invalidated)
177
+ if needs_authentication:
178
+ # Start the callback server
179
+ port = start_callback_server()
180
+
181
+ # Update auth manager's redirect URI with the current port
182
+ auth_manager.redirect_uri = f"http://localhost:{port}/callback"
183
+
184
+ # Generate the authentication URL
185
+ login_url = auth_manager.get_auth_url()
186
+
187
+ # Create a resource response that includes the markdown link format
188
+ response = {
189
+ "error": "Session expired or token invalid. Please re-authenticate with Meta Ads API",
190
+ "login_url": login_url,
191
+ "server_status": f"Callback server running on port {port}",
192
+ "markdown_link": f"[Click here to re-authenticate with Meta Ads API]({login_url})",
193
+ "message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
194
+ "instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
195
+ "note": "After authenticating, the token will be automatically saved."
196
+ }
197
+
198
+ # Wait a moment to ensure the server is fully started
199
+ await asyncio.sleep(1)
200
+
201
+ return json.dumps(response, indent=2)
202
+
203
+ return result
204
+ except Exception as e:
205
+ # Handle any unexpected errors
206
+ error_result = {
207
+ "error": f"Error calling Meta API: {str(e)}"
208
+ }
209
+ return json.dumps(error_result, indent=2)
210
+
211
+ return wrapper
@@ -0,0 +1,416 @@
1
+ """Authentication related functionality for Meta Ads API."""
2
+
3
+ from typing import Any, Dict, Optional
4
+ import time
5
+ import platform
6
+ import pathlib
7
+ import os
8
+ import threading
9
+ import socket
10
+ import webbrowser
11
+ import asyncio
12
+ from urllib.parse import urlparse, parse_qs
13
+ from http.server import HTTPServer, BaseHTTPRequestHandler
14
+ import json
15
+
16
+ # Auth constants
17
+ AUTH_SCOPE = "ads_management,ads_read,business_management"
18
+ AUTH_REDIRECT_URI = "http://localhost:8888/callback"
19
+ AUTH_RESPONSE_TYPE = "token"
20
+
21
+ # Global flag for authentication state
22
+ needs_authentication = False
23
+
24
+ # Global variable for server thread and state
25
+ callback_server_thread = None
26
+ callback_server_lock = threading.Lock()
27
+ callback_server_running = False
28
+ callback_server_port = None
29
+
30
+ # Global token container for communication between threads
31
+ token_container = {"token": None, "expires_in": None, "user_id": None}
32
+
33
+ class TokenInfo:
34
+ """Stores token information including expiration"""
35
+ def __init__(self, access_token: str, expires_in: int = None, user_id: str = None):
36
+ self.access_token = access_token
37
+ self.expires_in = expires_in
38
+ self.user_id = user_id
39
+ self.created_at = int(time.time())
40
+
41
+ def is_expired(self) -> bool:
42
+ """Check if the token is expired"""
43
+ if not self.expires_in:
44
+ return False # If no expiration is set, assume it's not expired
45
+
46
+ current_time = int(time.time())
47
+ return current_time > (self.created_at + self.expires_in)
48
+
49
+ def serialize(self) -> Dict[str, Any]:
50
+ """Convert to a dictionary for storage"""
51
+ return {
52
+ "access_token": self.access_token,
53
+ "expires_in": self.expires_in,
54
+ "user_id": self.user_id,
55
+ "created_at": self.created_at
56
+ }
57
+
58
+ @classmethod
59
+ def deserialize(cls, data: Dict[str, Any]) -> 'TokenInfo':
60
+ """Create from a stored dictionary"""
61
+ token = cls(
62
+ access_token=data.get("access_token", ""),
63
+ expires_in=data.get("expires_in"),
64
+ user_id=data.get("user_id")
65
+ )
66
+ token.created_at = data.get("created_at", int(time.time()))
67
+ return token
68
+
69
+
70
+ class AuthManager:
71
+ """Manages authentication with Meta APIs"""
72
+ def __init__(self, app_id: str, redirect_uri: str = AUTH_REDIRECT_URI):
73
+ self.app_id = app_id
74
+ self.redirect_uri = redirect_uri
75
+ self.token_info = None
76
+ self._load_cached_token()
77
+
78
+ def _get_token_cache_path(self) -> pathlib.Path:
79
+ """Get the platform-specific path for token cache file"""
80
+ if platform.system() == "Windows":
81
+ base_path = pathlib.Path(os.environ.get("APPDATA", ""))
82
+ elif platform.system() == "Darwin": # macOS
83
+ base_path = pathlib.Path.home() / "Library" / "Application Support"
84
+ else: # Assume Linux/Unix
85
+ base_path = pathlib.Path.home() / ".config"
86
+
87
+ # Create directory if it doesn't exist
88
+ cache_dir = base_path / "meta-ads-mcp"
89
+ cache_dir.mkdir(parents=True, exist_ok=True)
90
+
91
+ return cache_dir / "token_cache.json"
92
+
93
+ def _load_cached_token(self) -> bool:
94
+ """Load token from cache if available"""
95
+ cache_path = self._get_token_cache_path()
96
+
97
+ if not cache_path.exists():
98
+ return False
99
+
100
+ try:
101
+ with open(cache_path, "r") as f:
102
+ data = json.load(f)
103
+ self.token_info = TokenInfo.deserialize(data)
104
+
105
+ # Check if token is expired
106
+ if self.token_info.is_expired():
107
+ print("Cached token is expired")
108
+ self.token_info = None
109
+ return False
110
+
111
+ print(f"Loaded cached token (expires in {(self.token_info.created_at + self.token_info.expires_in) - int(time.time())} seconds)")
112
+ return True
113
+ except Exception as e:
114
+ print(f"Error loading cached token: {e}")
115
+ return False
116
+
117
+ def _save_token_to_cache(self) -> None:
118
+ """Save token to cache file"""
119
+ if not self.token_info:
120
+ return
121
+
122
+ cache_path = self._get_token_cache_path()
123
+
124
+ try:
125
+ with open(cache_path, "w") as f:
126
+ json.dump(self.token_info.serialize(), f)
127
+ print(f"Token cached at: {cache_path}")
128
+ except Exception as e:
129
+ print(f"Error saving token to cache: {e}")
130
+
131
+ def get_auth_url(self) -> str:
132
+ """Generate the Facebook OAuth URL for desktop app flow"""
133
+ return (
134
+ f"https://www.facebook.com/v18.0/dialog/oauth?"
135
+ f"client_id={self.app_id}&"
136
+ f"redirect_uri={self.redirect_uri}&"
137
+ f"scope={AUTH_SCOPE}&"
138
+ f"response_type={AUTH_RESPONSE_TYPE}"
139
+ )
140
+
141
+ def authenticate(self, force_refresh: bool = False) -> Optional[str]:
142
+ """
143
+ Authenticate with Meta APIs
144
+
145
+ Args:
146
+ force_refresh: Force token refresh even if cached token exists
147
+
148
+ Returns:
149
+ Access token if successful, None otherwise
150
+ """
151
+ # Check if we already have a valid token
152
+ if not force_refresh and self.token_info and not self.token_info.is_expired():
153
+ return self.token_info.access_token
154
+
155
+ # Start the callback server if not already running
156
+ port = start_callback_server()
157
+
158
+ # Generate the auth URL
159
+ auth_url = self.get_auth_url()
160
+
161
+ # Open browser with auth URL
162
+ print(f"Opening browser with URL: {auth_url}")
163
+ webbrowser.open(auth_url)
164
+
165
+ # We don't wait for the token here anymore
166
+ # The token will be processed by the callback server
167
+ # Just return None to indicate we've started the flow
168
+ return None
169
+
170
+ def get_access_token(self) -> Optional[str]:
171
+ """
172
+ Get the current access token, refreshing if necessary
173
+
174
+ Returns:
175
+ Access token if available, None otherwise
176
+ """
177
+ if not self.token_info or self.token_info.is_expired():
178
+ return None
179
+
180
+ return self.token_info.access_token
181
+
182
+ def invalidate_token(self) -> None:
183
+ """Invalidate the current token, usually because it has expired or is invalid"""
184
+ if self.token_info:
185
+ print(f"Invalidating token: {self.token_info.access_token[:10]}...")
186
+ self.token_info = None
187
+
188
+ # Signal that authentication is needed
189
+ global needs_authentication
190
+ needs_authentication = True
191
+
192
+ # Remove the cached token file
193
+ try:
194
+ cache_path = self._get_token_cache_path()
195
+ if cache_path.exists():
196
+ os.remove(cache_path)
197
+ print(f"Removed cached token file: {cache_path}")
198
+ except Exception as e:
199
+ print(f"Error removing cached token: {e}")
200
+
201
+ def clear_token(self) -> None:
202
+ """Clear the current token and remove from cache"""
203
+ self.invalidate_token()
204
+
205
+
206
+ # Callback Handler class definition
207
+ class CallbackHandler(BaseHTTPRequestHandler):
208
+ def do_GET(self):
209
+ global token_container, auth_manager, needs_authentication
210
+
211
+ if self.path.startswith("/callback"):
212
+ # Return a page that extracts token from URL hash fragment
213
+ self.send_response(200)
214
+ self.send_header("Content-type", "text/html")
215
+ self.end_headers()
216
+
217
+ html = """
218
+ <html>
219
+ <head><title>Authentication Successful</title></head>
220
+ <body>
221
+ <h1>Authentication Successful!</h1>
222
+ <p>You can close this window and return to the application.</p>
223
+ <script>
224
+ // Extract token from URL hash
225
+ const hash = window.location.hash.substring(1);
226
+ const params = new URLSearchParams(hash);
227
+ const token = params.get('access_token');
228
+ const expires_in = params.get('expires_in');
229
+
230
+ // Send token back to server using fetch
231
+ fetch('/token?' + new URLSearchParams({
232
+ token: token,
233
+ expires_in: expires_in
234
+ }))
235
+ .then(response => console.log('Token sent to app'));
236
+ </script>
237
+ </body>
238
+ </html>
239
+ """
240
+ self.wfile.write(html.encode())
241
+ return
242
+
243
+ if self.path.startswith("/token"):
244
+ # Extract token from query params
245
+ query = parse_qs(urlparse(self.path).query)
246
+ token_container["token"] = query.get("token", [""])[0]
247
+
248
+ if "expires_in" in query:
249
+ try:
250
+ token_container["expires_in"] = int(query.get("expires_in", ["0"])[0])
251
+ except ValueError:
252
+ token_container["expires_in"] = None
253
+
254
+ # Send success response
255
+ self.send_response(200)
256
+ self.send_header("Content-type", "text/plain")
257
+ self.end_headers()
258
+ self.wfile.write(b"Token received")
259
+
260
+ # Process the token (save it) immediately
261
+ if token_container["token"]:
262
+ # Create token info and save to cache
263
+ token_info = TokenInfo(
264
+ access_token=token_container["token"],
265
+ expires_in=token_container["expires_in"]
266
+ )
267
+ auth_manager.token_info = token_info
268
+ auth_manager._save_token_to_cache()
269
+
270
+ # Reset auth needed flag
271
+ needs_authentication = False
272
+
273
+ print(f"Token received and cached (expires in {token_container['expires_in']} seconds)")
274
+ return
275
+
276
+ # Silence server logs
277
+ def log_message(self, format, *args):
278
+ return
279
+
280
+
281
+ def start_callback_server():
282
+ """Start the callback server if it's not already running"""
283
+ global callback_server_thread, callback_server_running, callback_server_port, auth_manager
284
+
285
+ with callback_server_lock:
286
+ if callback_server_running:
287
+ print(f"Callback server already running on port {callback_server_port}")
288
+ return callback_server_port
289
+
290
+ # Find an available port
291
+ port = 8888
292
+ max_attempts = 10
293
+ for attempt in range(max_attempts):
294
+ try:
295
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
296
+ s.bind(('localhost', port))
297
+ break
298
+ except OSError:
299
+ port += 1
300
+ if attempt == max_attempts - 1:
301
+ raise Exception(f"Could not find an available port after {max_attempts} attempts")
302
+
303
+ # Update auth manager's redirect URI with new port
304
+ if 'auth_manager' in globals():
305
+ auth_manager.redirect_uri = f"http://localhost:{port}/callback"
306
+ callback_server_port = port
307
+
308
+ try:
309
+ # Get the CallbackHandler class from global scope
310
+ handler_class = globals()['CallbackHandler']
311
+
312
+ # Create and start server in a daemon thread
313
+ server = HTTPServer(('localhost', port), handler_class)
314
+ print(f"Callback server starting on port {port}")
315
+
316
+ # Create a simple flag to signal when the server is ready
317
+ server_ready = threading.Event()
318
+
319
+ def server_thread():
320
+ try:
321
+ # Signal that the server thread has started
322
+ server_ready.set()
323
+ print(f"Callback server is now ready on port {port}")
324
+ # Start serving HTTP requests
325
+ server.serve_forever()
326
+ except Exception as e:
327
+ print(f"Server error: {e}")
328
+ finally:
329
+ with callback_server_lock:
330
+ global callback_server_running
331
+ callback_server_running = False
332
+
333
+ callback_server_thread = threading.Thread(target=server_thread)
334
+ callback_server_thread.daemon = True
335
+ callback_server_thread.start()
336
+
337
+ # Wait for server to be ready (up to 5 seconds)
338
+ if not server_ready.wait(timeout=5):
339
+ print("Warning: Timeout waiting for server to start, but continuing anyway")
340
+
341
+ callback_server_running = True
342
+
343
+ # Verify the server is actually accepting connections
344
+ try:
345
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
346
+ s.settimeout(2)
347
+ s.connect(('localhost', port))
348
+ print(f"Confirmed server is accepting connections on port {port}")
349
+ except Exception as e:
350
+ print(f"Warning: Could not verify server connection: {e}")
351
+
352
+ return port
353
+
354
+ except Exception as e:
355
+ print(f"Error starting callback server: {e}")
356
+ # Try again with a different port in case of bind issues
357
+ if "address already in use" in str(e).lower():
358
+ print("Port may be in use, trying a different port...")
359
+ return start_callback_server() # Recursive call with a new port
360
+ raise e
361
+
362
+
363
+ async def get_current_access_token() -> Optional[str]:
364
+ """
365
+ Get the current access token from cache
366
+
367
+ Returns:
368
+ Current access token or None if not available
369
+ """
370
+ return auth_manager.get_access_token()
371
+
372
+
373
+ def login():
374
+ """
375
+ Start the login flow to authenticate with Meta
376
+ """
377
+ print("Starting Meta Ads authentication flow...")
378
+
379
+ try:
380
+ # Start the callback server first
381
+ port = start_callback_server()
382
+
383
+ # Get the auth URL and open the browser
384
+ auth_url = auth_manager.get_auth_url()
385
+ print(f"Opening browser with URL: {auth_url}")
386
+ webbrowser.open(auth_url)
387
+
388
+ # Wait for token to be received
389
+ print("Waiting for authentication to complete...")
390
+ max_wait = 300 # 5 minutes
391
+ wait_interval = 2 # 2 seconds
392
+
393
+ for _ in range(max_wait // wait_interval):
394
+ if token_container["token"]:
395
+ token = token_container["token"]
396
+ print("Authentication successful!")
397
+ # Verify token works by getting basic user info
398
+ try:
399
+ from .api import make_api_request
400
+ result = asyncio.run(make_api_request("me", token, {}))
401
+ print(f"Authenticated as: {result.get('name', 'Unknown')} (ID: {result.get('id', 'Unknown')})")
402
+ return
403
+ except Exception as e:
404
+ print(f"Warning: Could not verify token: {e}")
405
+ return
406
+ time.sleep(wait_interval)
407
+
408
+ print("Authentication timed out. Please try again.")
409
+ except Exception as e:
410
+ print(f"Error during authentication: {e}")
411
+ print(f"Direct authentication URL: {auth_manager.get_auth_url()}")
412
+ print("You can manually open this URL in your browser to complete authentication.")
413
+
414
+ # Initialize auth manager with a placeholder - will be updated at runtime
415
+ META_APP_ID = os.environ.get("META_APP_ID", "YOUR_META_APP_ID")
416
+ auth_manager = AuthManager(META_APP_ID)
@@ -0,0 +1,56 @@
1
+ """Authentication-specific functionality for Meta Ads API."""
2
+
3
+ import json
4
+ import asyncio
5
+ from .api import meta_api_tool
6
+ from .auth import start_callback_server, auth_manager
7
+
8
+
9
+ @meta_api_tool
10
+ async def get_login_link(access_token: str = None) -> str:
11
+ """
12
+ Get a clickable login link for Meta Ads authentication.
13
+
14
+ Args:
15
+ access_token: Meta API access token (optional - will use cached token if not provided)
16
+
17
+ Returns:
18
+ A clickable resource link for Meta authentication
19
+ """
20
+ # Check if we have a cached token
21
+ cached_token = auth_manager.get_access_token()
22
+ token_status = "No token" if not cached_token else "Valid token"
23
+
24
+ # If we already have a valid token and none was provided, just return success
25
+ if cached_token and not access_token:
26
+ return json.dumps({
27
+ "message": "Already authenticated",
28
+ "token_status": token_status,
29
+ "token_preview": cached_token[:10] + "...",
30
+ "created_at": auth_manager.token_info.created_at,
31
+ "expires_in": auth_manager.token_info.expires_in
32
+ }, indent=2)
33
+
34
+ # IMPORTANT: Start the callback server first by calling our helper function
35
+ # This ensures the server is ready before we provide the URL to the user
36
+ port = start_callback_server()
37
+
38
+ # Generate direct login URL
39
+ auth_manager.redirect_uri = f"http://localhost:{port}/callback" # Ensure port is set correctly
40
+ login_url = auth_manager.get_auth_url()
41
+
42
+ # Return a special format that helps the LLM format the response properly
43
+ response = {
44
+ "login_url": login_url,
45
+ "token_status": token_status,
46
+ "server_status": f"Callback server running on port {port}",
47
+ "markdown_link": f"[Click here to authenticate with Meta Ads]({login_url})",
48
+ "message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
49
+ "instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
50
+ "note": "After authenticating, the token will be automatically saved."
51
+ }
52
+
53
+ # Wait a moment to ensure the server is fully started
54
+ await asyncio.sleep(1)
55
+
56
+ return json.dumps(response, indent=2)