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