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')
|