meta-ads-mcp 0.2.3__py3-none-any.whl → 0.2.4__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 +21 -3
- meta_ads_mcp/api.py +360 -273
- meta_ads_mcp/core/__init__.py +1 -2
- meta_ads_mcp/core/accounts.py +3 -0
- meta_ads_mcp/core/ads.py +5 -0
- meta_ads_mcp/core/adsets.py +52 -28
- meta_ads_mcp/core/api.py +112 -103
- meta_ads_mcp/core/auth.py +459 -12
- meta_ads_mcp/core/authentication.py +11 -4
- meta_ads_mcp/core/campaigns.py +4 -0
- meta_ads_mcp/core/insights.py +4 -1
- meta_ads_mcp/core/server.py +33 -5
- meta_ads_mcp/core/utils.py +43 -0
- {meta_ads_mcp-0.2.3.dist-info → meta_ads_mcp-0.2.4.dist-info}/METADATA +56 -32
- meta_ads_mcp-0.2.4.dist-info/RECORD +19 -0
- meta_ads_mcp-0.2.3.dist-info/RECORD +0 -19
- {meta_ads_mcp-0.2.3.dist-info → meta_ads_mcp-0.2.4.dist-info}/WHEEL +0 -0
- {meta_ads_mcp-0.2.3.dist-info → meta_ads_mcp-0.2.4.dist-info}/entry_points.txt +0 -0
meta_ads_mcp/api.py
CHANGED
|
@@ -26,15 +26,8 @@ META_GRAPH_API_VERSION = "v20.0"
|
|
|
26
26
|
META_GRAPH_API_BASE = f"https://graph.facebook.com/{META_GRAPH_API_VERSION}"
|
|
27
27
|
USER_AGENT = "meta-ads-mcp/1.0"
|
|
28
28
|
|
|
29
|
-
# Meta App configuration
|
|
30
|
-
|
|
31
|
-
# 2. Environment variables
|
|
32
|
-
# 3. User input during runtime
|
|
33
|
-
META_APP_ID = "YOUR_META_APP_ID" # Will be replaced at runtime
|
|
34
|
-
|
|
35
|
-
# Try to load from environment variable
|
|
36
|
-
if os.environ.get("META_APP_ID"):
|
|
37
|
-
META_APP_ID = os.environ.get("META_APP_ID")
|
|
29
|
+
# Meta App configuration
|
|
30
|
+
META_APP_ID = os.environ.get("META_APP_ID", "") # Default to empty string
|
|
38
31
|
|
|
39
32
|
# Auth constants
|
|
40
33
|
AUTH_SCOPE = "ads_management,ads_read,business_management"
|
|
@@ -55,6 +48,35 @@ callback_server_port = None
|
|
|
55
48
|
# Global token container for communication between threads
|
|
56
49
|
token_container = {"token": None, "expires_in": None, "user_id": None}
|
|
57
50
|
|
|
51
|
+
# Configuration class to store app ID and other config
|
|
52
|
+
class MetaConfig:
|
|
53
|
+
_instance = None
|
|
54
|
+
|
|
55
|
+
def __new__(cls):
|
|
56
|
+
if cls._instance is None:
|
|
57
|
+
cls._instance = super(MetaConfig, cls).__new__(cls)
|
|
58
|
+
cls._instance.app_id = os.environ.get("META_APP_ID", "") # Default from env
|
|
59
|
+
cls._instance.initialized = False
|
|
60
|
+
return cls._instance
|
|
61
|
+
|
|
62
|
+
def set_app_id(self, app_id):
|
|
63
|
+
"""Set app ID from CLI or other source"""
|
|
64
|
+
if app_id:
|
|
65
|
+
print(f"Setting Meta App ID: {app_id}")
|
|
66
|
+
self.app_id = app_id
|
|
67
|
+
self.initialized = True
|
|
68
|
+
|
|
69
|
+
def get_app_id(self):
|
|
70
|
+
"""Get current app ID"""
|
|
71
|
+
return self.app_id
|
|
72
|
+
|
|
73
|
+
def is_configured(self):
|
|
74
|
+
"""Check if app has been configured with valid app ID"""
|
|
75
|
+
return bool(self.app_id)
|
|
76
|
+
|
|
77
|
+
# Create global config instance
|
|
78
|
+
meta_config = MetaConfig()
|
|
79
|
+
|
|
58
80
|
# Callback Handler class definition
|
|
59
81
|
class CallbackHandler(BaseHTTPRequestHandler):
|
|
60
82
|
def do_GET(self):
|
|
@@ -302,8 +324,8 @@ class AuthManager:
|
|
|
302
324
|
"""Clear the current token and remove from cache"""
|
|
303
325
|
self.invalidate_token()
|
|
304
326
|
|
|
305
|
-
# Initialize auth manager
|
|
306
|
-
auth_manager = AuthManager(
|
|
327
|
+
# Initialize auth manager with app_id from config
|
|
328
|
+
auth_manager = AuthManager(meta_config.get_app_id())
|
|
307
329
|
|
|
308
330
|
# Function to get token without requiring it as a parameter
|
|
309
331
|
async def get_current_access_token() -> Optional[str]:
|
|
@@ -315,6 +337,24 @@ async def get_current_access_token() -> Optional[str]:
|
|
|
315
337
|
"""
|
|
316
338
|
return auth_manager.get_access_token()
|
|
317
339
|
|
|
340
|
+
# Function to get current app ID from all possible sources
|
|
341
|
+
def get_current_app_id():
|
|
342
|
+
"""Get the current app ID from MetaConfig."""
|
|
343
|
+
# First try to get from our singleton config
|
|
344
|
+
app_id = meta_config.get_app_id()
|
|
345
|
+
if app_id:
|
|
346
|
+
return app_id
|
|
347
|
+
|
|
348
|
+
# If not in config yet, check environment as fallback
|
|
349
|
+
env_app_id = os.environ.get("META_APP_ID", "")
|
|
350
|
+
if env_app_id:
|
|
351
|
+
# Update config for future use
|
|
352
|
+
meta_config.set_app_id(env_app_id)
|
|
353
|
+
return env_app_id
|
|
354
|
+
|
|
355
|
+
# Last resort, return empty string
|
|
356
|
+
return ""
|
|
357
|
+
|
|
318
358
|
class GraphAPIError(Exception):
|
|
319
359
|
"""Exception raised for errors from the Graph API."""
|
|
320
360
|
def __init__(self, error_data: Dict[str, Any]):
|
|
@@ -345,6 +385,17 @@ async def make_api_request(
|
|
|
345
385
|
Returns:
|
|
346
386
|
API response as a dictionary
|
|
347
387
|
"""
|
|
388
|
+
# Validate access token before proceeding
|
|
389
|
+
if not access_token:
|
|
390
|
+
print("API request attempted with blank access token")
|
|
391
|
+
return {
|
|
392
|
+
"error": "Authentication Required",
|
|
393
|
+
"details": {
|
|
394
|
+
"message": "A valid access token is required to access the Meta API",
|
|
395
|
+
"action_required": "Please authenticate first"
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
348
399
|
url = f"{META_GRAPH_API_BASE}/{endpoint}"
|
|
349
400
|
|
|
350
401
|
headers = {
|
|
@@ -391,6 +442,19 @@ async def make_api_request(
|
|
|
391
442
|
# Check for specific FB API errors related to auth
|
|
392
443
|
if isinstance(error_obj, dict) and error_obj.get("code") in [190, 102, 4, 200, 10]:
|
|
393
444
|
print(f"Detected Facebook API auth error: {error_obj.get('code')}")
|
|
445
|
+
# For app ID errors, provide more useful error message
|
|
446
|
+
if error_obj.get("code") == 200 and "Provide valid app ID" in error_obj.get("message", ""):
|
|
447
|
+
print("Meta API authentication configuration issue")
|
|
448
|
+
app_id = auth_manager.app_id
|
|
449
|
+
print(f"Current app_id: {app_id}")
|
|
450
|
+
return {
|
|
451
|
+
"error": f"HTTP Error: {e.response.status_code}",
|
|
452
|
+
"details": {
|
|
453
|
+
"message": "Meta API authentication configuration issue. Please check your app credentials.",
|
|
454
|
+
"original_error": error_obj.get("message"),
|
|
455
|
+
"code": error_obj.get("code")
|
|
456
|
+
}
|
|
457
|
+
}
|
|
394
458
|
auth_manager.invalidate_token()
|
|
395
459
|
|
|
396
460
|
return {"error": f"HTTP Error: {e.response.status_code}", "details": error_info}
|
|
@@ -403,6 +467,7 @@ async def make_api_request(
|
|
|
403
467
|
def meta_api_tool(func):
|
|
404
468
|
"""Decorator to handle authentication for all Meta API tools"""
|
|
405
469
|
async def wrapper(*args, **kwargs):
|
|
470
|
+
global needs_authentication
|
|
406
471
|
# Handle various MCP invocation patterns
|
|
407
472
|
if len(args) == 1:
|
|
408
473
|
# MCP might pass a single string argument that contains JSON
|
|
@@ -449,39 +514,44 @@ def meta_api_tool(func):
|
|
|
449
514
|
# If not, try to get it from the auth manager
|
|
450
515
|
if not access_token:
|
|
451
516
|
access_token = await get_current_access_token()
|
|
517
|
+
if access_token:
|
|
518
|
+
kwargs['access_token'] = access_token
|
|
452
519
|
|
|
453
520
|
# If still no token, we need authentication
|
|
454
521
|
if not access_token:
|
|
455
|
-
global needs_authentication
|
|
456
522
|
needs_authentication = True
|
|
457
523
|
|
|
458
524
|
# Start the callback server
|
|
459
525
|
port = start_callback_server()
|
|
460
526
|
|
|
527
|
+
# Get current app ID from config
|
|
528
|
+
current_app_id = meta_config.get_app_id()
|
|
529
|
+
if not current_app_id:
|
|
530
|
+
return json.dumps({
|
|
531
|
+
"error": "No Meta App ID provided. Please provide a valid app ID via environment variable META_APP_ID or --app-id CLI argument.",
|
|
532
|
+
"help": "This is required for authentication with Meta Graph API."
|
|
533
|
+
}, indent=2)
|
|
534
|
+
|
|
535
|
+
# Update auth manager with current app ID
|
|
536
|
+
auth_manager.app_id = current_app_id
|
|
537
|
+
print(f"Using Meta App ID from config: {current_app_id}")
|
|
538
|
+
|
|
461
539
|
# Update auth manager's redirect URI with the current port
|
|
462
540
|
auth_manager.redirect_uri = f"http://localhost:{port}/callback"
|
|
463
541
|
|
|
464
542
|
# Generate the authentication URL
|
|
465
543
|
login_url = auth_manager.get_auth_url()
|
|
466
544
|
|
|
467
|
-
#
|
|
468
|
-
|
|
469
|
-
"error": "Authentication
|
|
470
|
-
"
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
# Wait a moment to ensure the server is fully started
|
|
479
|
-
await asyncio.sleep(1)
|
|
480
|
-
|
|
481
|
-
return json.dumps(response, indent=2)
|
|
482
|
-
|
|
483
|
-
# Update kwargs with the token
|
|
484
|
-
kwargs['access_token'] = access_token
|
|
545
|
+
# Return a user-friendly authentication required response
|
|
546
|
+
return json.dumps({
|
|
547
|
+
"error": "Authentication Required",
|
|
548
|
+
"details": {
|
|
549
|
+
"message": "You need to authenticate with the Meta API before using this tool",
|
|
550
|
+
"action_required": "Please authenticate using the link below",
|
|
551
|
+
"login_url": login_url,
|
|
552
|
+
"markdown_link": f"[Click here to authenticate with Meta Ads API]({login_url})"
|
|
553
|
+
}
|
|
554
|
+
}, indent=2)
|
|
485
555
|
|
|
486
556
|
# Call the original function
|
|
487
557
|
try:
|
|
@@ -492,6 +562,17 @@ def meta_api_tool(func):
|
|
|
492
562
|
# Start the callback server
|
|
493
563
|
port = start_callback_server()
|
|
494
564
|
|
|
565
|
+
# Get current app ID from config
|
|
566
|
+
current_app_id = meta_config.get_app_id()
|
|
567
|
+
if not current_app_id:
|
|
568
|
+
return json.dumps({
|
|
569
|
+
"error": "No Meta App ID provided. Please provide a valid app ID via environment variable META_APP_ID or --app-id CLI argument.",
|
|
570
|
+
"help": "This is required for authentication with Meta Graph API."
|
|
571
|
+
}, indent=2)
|
|
572
|
+
|
|
573
|
+
auth_manager.app_id = current_app_id
|
|
574
|
+
print(f"Using Meta App ID from config: {current_app_id}")
|
|
575
|
+
|
|
495
576
|
# Update auth manager's redirect URI with the current port
|
|
496
577
|
auth_manager.redirect_uri = f"http://localhost:{port}/callback"
|
|
497
578
|
|
|
@@ -514,6 +595,28 @@ def meta_api_tool(func):
|
|
|
514
595
|
|
|
515
596
|
return json.dumps(response, indent=2)
|
|
516
597
|
|
|
598
|
+
# If result is a string (JSON), check for app ID errors and improve them
|
|
599
|
+
if isinstance(result, str):
|
|
600
|
+
try:
|
|
601
|
+
result_obj = json.loads(result)
|
|
602
|
+
if "error" in result_obj and "details" in result_obj and "error" in result_obj["details"]:
|
|
603
|
+
error_obj = result_obj["details"].get("error", {})
|
|
604
|
+
if isinstance(error_obj, dict) and error_obj.get("code") == 200 and "Provide valid app ID" in error_obj.get("message", ""):
|
|
605
|
+
# Replace with more user-friendly message
|
|
606
|
+
app_id = auth_manager.app_id
|
|
607
|
+
return json.dumps({
|
|
608
|
+
"error": "Meta API Configuration Issue",
|
|
609
|
+
"details": {
|
|
610
|
+
"message": "Your Meta API app is not properly configured",
|
|
611
|
+
"action_required": "Check your META_APP_ID environment variable",
|
|
612
|
+
"current_app_id": app_id,
|
|
613
|
+
"original_error": error_obj.get("message")
|
|
614
|
+
}
|
|
615
|
+
}, indent=2)
|
|
616
|
+
except Exception:
|
|
617
|
+
# Not JSON or other parsing error, just continue
|
|
618
|
+
pass
|
|
619
|
+
|
|
517
620
|
return result
|
|
518
621
|
except Exception as e:
|
|
519
622
|
# Handle any unexpected errors
|
|
@@ -1171,10 +1274,35 @@ async def get_insights(
|
|
|
1171
1274
|
breakdown: Optional breakdown dimension (e.g., age, gender, country)
|
|
1172
1275
|
level: Level of aggregation (ad, adset, campaign, account)
|
|
1173
1276
|
"""
|
|
1277
|
+
# Import logger
|
|
1278
|
+
from meta_ads_mcp.core.utils import logger
|
|
1279
|
+
|
|
1280
|
+
# Log function call details
|
|
1281
|
+
logger.info(f"get_insights called with object_id: {object_id}, time_range: {time_range}, level: {level}")
|
|
1282
|
+
|
|
1283
|
+
# Log authentication details
|
|
1284
|
+
from meta_ads_mcp.core.auth import meta_config
|
|
1285
|
+
app_id = meta_config.get_app_id()
|
|
1286
|
+
logger.info(f"App ID from meta_config: {app_id}")
|
|
1287
|
+
|
|
1288
|
+
# Validate inputs
|
|
1174
1289
|
if not object_id:
|
|
1175
|
-
|
|
1290
|
+
logger.error("No object ID provided for get_insights")
|
|
1291
|
+
return json.dumps({"error": "Missing Required Parameter", "details": {"message": "No object ID provided"}}, indent=2)
|
|
1176
1292
|
|
|
1293
|
+
# Check access token (masking for security)
|
|
1294
|
+
token_status = "provided" if access_token else "not provided"
|
|
1295
|
+
logger.info(f"Access token status: {token_status}")
|
|
1296
|
+
|
|
1297
|
+
# Log the specific object ID format for troubleshooting
|
|
1298
|
+
if object_id.startswith("act_"):
|
|
1299
|
+
logger.info(f"Object ID is an account ID: {object_id}")
|
|
1300
|
+
else:
|
|
1301
|
+
logger.info(f"Object ID format: {object_id}")
|
|
1302
|
+
|
|
1177
1303
|
endpoint = f"{object_id}/insights"
|
|
1304
|
+
logger.info(f"Using endpoint: {endpoint}")
|
|
1305
|
+
|
|
1178
1306
|
params = {
|
|
1179
1307
|
"date_preset": time_range,
|
|
1180
1308
|
"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",
|
|
@@ -1184,43 +1312,52 @@ async def get_insights(
|
|
|
1184
1312
|
if breakdown:
|
|
1185
1313
|
params["breakdowns"] = breakdown
|
|
1186
1314
|
|
|
1187
|
-
|
|
1315
|
+
logger.info("Making API request for insights")
|
|
1316
|
+
try:
|
|
1317
|
+
data = await make_api_request(endpoint, access_token, params)
|
|
1318
|
+
logger.info(f"API response received: {'success' if 'error' not in data else 'error'}")
|
|
1319
|
+
|
|
1320
|
+
# Check for specific app ID errors and improve error message
|
|
1321
|
+
if "error" in data:
|
|
1322
|
+
error_details = data.get("details", {}).get("error", {})
|
|
1323
|
+
if isinstance(error_details, dict) and error_details.get("code") == 200:
|
|
1324
|
+
logger.error(f"Authentication configuration error in response: {error_details.get('message')}")
|
|
1325
|
+
return json.dumps({
|
|
1326
|
+
"error": "Meta API Configuration Issue",
|
|
1327
|
+
"details": {
|
|
1328
|
+
"message": "There is an issue with your Meta API configuration",
|
|
1329
|
+
"action_required": "Check your META_APP_ID environment variable or re-authenticate",
|
|
1330
|
+
"current_app_id": app_id
|
|
1331
|
+
}
|
|
1332
|
+
}, indent=2)
|
|
1333
|
+
except Exception as e:
|
|
1334
|
+
logger.error(f"Exception during get_insights API call: {str(e)}")
|
|
1335
|
+
data = {"error": str(e)}
|
|
1188
1336
|
|
|
1189
1337
|
return json.dumps(data, indent=2)
|
|
1190
1338
|
|
|
1191
1339
|
@mcp_server.tool()
|
|
1192
1340
|
@meta_api_tool
|
|
1193
|
-
async def debug_image_download(
|
|
1194
|
-
"""
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
Args:
|
|
1198
|
-
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
1199
|
-
url: Direct image URL to test (optional)
|
|
1200
|
-
ad_id: Meta Ads ad ID (optional, used if url is not provided)
|
|
1201
|
-
"""
|
|
1202
|
-
results = {
|
|
1203
|
-
"diagnostics": {
|
|
1204
|
-
"timestamp": str(datetime.datetime.now()),
|
|
1205
|
-
"methods_tried": [],
|
|
1206
|
-
"request_details": [],
|
|
1207
|
-
"network_info": {}
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1341
|
+
async def debug_image_download(url="", ad_id="", access_token=None):
|
|
1342
|
+
"""Debug image download issues and report detailed diagnostics."""
|
|
1343
|
+
results = {}
|
|
1210
1344
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
#
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1345
|
+
if url:
|
|
1346
|
+
results["image_url"] = url
|
|
1347
|
+
else:
|
|
1348
|
+
# If no URL provided but ad_id is, get URL from ad creative
|
|
1349
|
+
if ad_id:
|
|
1350
|
+
print(f"Getting image URL from ad creative for ad {ad_id}")
|
|
1351
|
+
# Get the creative details
|
|
1352
|
+
creative_json = await get_ad_creatives(access_token=access_token, ad_id=ad_id)
|
|
1353
|
+
creative_data = json.loads(creative_json)
|
|
1354
|
+
results["creative_data"] = creative_data
|
|
1355
|
+
|
|
1356
|
+
# Look for image URL in the creative
|
|
1357
|
+
if "full_image_url" in creative_data:
|
|
1358
|
+
url = creative_data.get("full_image_url")
|
|
1359
|
+
elif "thumbnail_url" in creative_data:
|
|
1360
|
+
url = creative_data.get("thumbnail_url")
|
|
1224
1361
|
|
|
1225
1362
|
if not url:
|
|
1226
1363
|
return json.dumps({
|
|
@@ -1229,29 +1366,13 @@ async def debug_image_download(access_token: str = None, url: str = "", ad_id: s
|
|
|
1229
1366
|
}, indent=2)
|
|
1230
1367
|
|
|
1231
1368
|
results["image_url"] = url
|
|
1232
|
-
print(f"Debug: Testing image URL: {url}")
|
|
1233
|
-
|
|
1234
|
-
# Try to get network information to help debug
|
|
1235
|
-
try:
|
|
1236
|
-
import socket
|
|
1237
|
-
hostname = urlparse(url).netloc
|
|
1238
|
-
ip_address = socket.gethostbyname(hostname)
|
|
1239
|
-
results["diagnostics"]["network_info"] = {
|
|
1240
|
-
"hostname": hostname,
|
|
1241
|
-
"ip_address": ip_address,
|
|
1242
|
-
"is_facebook_cdn": "fbcdn" in hostname
|
|
1243
|
-
}
|
|
1244
|
-
except Exception as e:
|
|
1245
|
-
results["diagnostics"]["network_info"] = {
|
|
1246
|
-
"error": str(e)
|
|
1247
|
-
}
|
|
1248
1369
|
|
|
1249
1370
|
# Method 1: Basic download
|
|
1250
1371
|
method_result = {
|
|
1251
1372
|
"method": "Basic download with standard headers",
|
|
1252
1373
|
"success": False
|
|
1253
1374
|
}
|
|
1254
|
-
results["diagnostics"]
|
|
1375
|
+
results["diagnostics"] = {"methods_tried": [method_result]}
|
|
1255
1376
|
|
|
1256
1377
|
try:
|
|
1257
1378
|
headers = {
|
|
@@ -1386,228 +1507,160 @@ async def save_ad_image_via_api(access_token: str = None, ad_id: str = None) ->
|
|
|
1386
1507
|
if not ad_id:
|
|
1387
1508
|
return json.dumps({"error": "No ad ID provided"}, indent=2)
|
|
1388
1509
|
|
|
1389
|
-
|
|
1510
|
+
print(f"Attempting to save image for ad {ad_id} using API methods")
|
|
1511
|
+
|
|
1512
|
+
# First, get creative ID and account ID
|
|
1390
1513
|
ad_endpoint = f"{ad_id}"
|
|
1391
1514
|
ad_params = {
|
|
1392
|
-
"fields": "creative,account_id"
|
|
1515
|
+
"fields": "creative{id},account_id"
|
|
1393
1516
|
}
|
|
1394
1517
|
|
|
1395
1518
|
ad_data = await make_api_request(ad_endpoint, access_token, ad_params)
|
|
1396
1519
|
|
|
1397
1520
|
if "error" in ad_data:
|
|
1398
|
-
return json.dumps({
|
|
1399
|
-
"error": "Could not get ad data",
|
|
1400
|
-
"details": ad_data
|
|
1401
|
-
}, indent=2)
|
|
1402
|
-
|
|
1403
|
-
if "creative" not in ad_data or "id" not in ad_data["creative"]:
|
|
1404
|
-
return json.dumps({
|
|
1405
|
-
"error": "No creative ID found for this ad",
|
|
1406
|
-
"ad_data": ad_data
|
|
1407
|
-
}, indent=2)
|
|
1521
|
+
return json.dumps({"error": f"Could not get ad data - {ad_data['error']}"}, indent=2)
|
|
1408
1522
|
|
|
1409
|
-
|
|
1523
|
+
# Extract account_id
|
|
1410
1524
|
account_id = ad_data.get("account_id", "")
|
|
1525
|
+
if not account_id:
|
|
1526
|
+
return json.dumps({"error": "No account ID found"}, indent=2)
|
|
1411
1527
|
|
|
1412
|
-
#
|
|
1528
|
+
# Extract creative ID
|
|
1529
|
+
if "creative" not in ad_data:
|
|
1530
|
+
return json.dumps({"error": "No creative found for this ad"}, indent=2)
|
|
1531
|
+
|
|
1532
|
+
creative_data = ad_data.get("creative", {})
|
|
1533
|
+
creative_id = creative_data.get("id")
|
|
1534
|
+
if not creative_id:
|
|
1535
|
+
return json.dumps({"error": "No creative ID found"}, indent=2)
|
|
1536
|
+
|
|
1537
|
+
# Get creative details to find image hash
|
|
1413
1538
|
creative_endpoint = f"{creative_id}"
|
|
1414
1539
|
creative_params = {
|
|
1415
|
-
"fields": "id,name,thumbnail_url,
|
|
1540
|
+
"fields": "id,name,image_hash,thumbnail_url,image_url,object_story_spec"
|
|
1416
1541
|
}
|
|
1417
1542
|
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
if "error" in creative_data:
|
|
1421
|
-
return json.dumps({
|
|
1422
|
-
"error": "Could not get creative data",
|
|
1423
|
-
"details": creative_data
|
|
1424
|
-
}, indent=2)
|
|
1543
|
+
creative_details = await make_api_request(creative_endpoint, access_token, creative_params)
|
|
1425
1544
|
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
if "image_hash" in creative_data:
|
|
1429
|
-
image_hash = creative_data["image_hash"]
|
|
1430
|
-
elif "asset_feed_spec" in creative_data and "images" in creative_data["asset_feed_spec"] and len(creative_data["asset_feed_spec"]["images"]) > 0:
|
|
1431
|
-
image_hash = creative_data["asset_feed_spec"]["images"][0].get("hash")
|
|
1545
|
+
if "error" in creative_details:
|
|
1546
|
+
return json.dumps({"error": f"Could not get creative details - {creative_details['error']}"}, indent=2)
|
|
1432
1547
|
|
|
1433
|
-
|
|
1548
|
+
results = {
|
|
1434
1549
|
"ad_id": ad_id,
|
|
1435
1550
|
"creative_id": creative_id,
|
|
1436
|
-
"
|
|
1551
|
+
"account_id": account_id,
|
|
1552
|
+
"creative_details": creative_details
|
|
1437
1553
|
}
|
|
1438
1554
|
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
"method": "adimages endpoint with hash",
|
|
1442
|
-
"success": False
|
|
1443
|
-
}
|
|
1444
|
-
result["attempts"].append(attempt)
|
|
1445
|
-
|
|
1446
|
-
try:
|
|
1447
|
-
image_endpoint = f"act_{account_id}/adimages"
|
|
1448
|
-
image_params = {
|
|
1449
|
-
"hashes": [image_hash]
|
|
1450
|
-
}
|
|
1451
|
-
image_data = await make_api_request(image_endpoint, access_token, image_params)
|
|
1452
|
-
attempt["response"] = image_data
|
|
1453
|
-
|
|
1454
|
-
if "data" in image_data and len(image_data["data"]) > 0 and "url" in image_data["data"][0]:
|
|
1455
|
-
url = image_data["data"][0]["url"]
|
|
1456
|
-
attempt["url"] = url
|
|
1457
|
-
|
|
1458
|
-
# Try to download the image
|
|
1459
|
-
image_bytes = await download_image(url)
|
|
1460
|
-
if image_bytes:
|
|
1461
|
-
attempt["success"] = True
|
|
1462
|
-
attempt["image_size"] = len(image_bytes)
|
|
1463
|
-
|
|
1464
|
-
# Save the image
|
|
1465
|
-
resource_id = f"ad_creative_{ad_id}_method1"
|
|
1466
|
-
resource_uri = f"meta-ads://images/{resource_id}"
|
|
1467
|
-
ad_creative_images[resource_id] = {
|
|
1468
|
-
"data": image_bytes,
|
|
1469
|
-
"mime_type": "image/jpeg",
|
|
1470
|
-
"name": f"Ad Creative for {ad_id} (Method 1)"
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
|
-
# Return success with resource info
|
|
1474
|
-
result["resource_uri"] = resource_uri
|
|
1475
|
-
result["success"] = True
|
|
1476
|
-
base64_sample = base64.b64encode(image_bytes[:100]).decode("utf-8") + "..."
|
|
1477
|
-
result["base64_sample"] = base64_sample
|
|
1478
|
-
except Exception as e:
|
|
1479
|
-
attempt["error"] = str(e)
|
|
1555
|
+
# Try to find image hash
|
|
1556
|
+
image_hash = None
|
|
1480
1557
|
|
|
1481
|
-
#
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1558
|
+
# Direct hash on creative
|
|
1559
|
+
if "image_hash" in creative_details:
|
|
1560
|
+
image_hash = creative_details["image_hash"]
|
|
1561
|
+
|
|
1562
|
+
# Look in object_story_spec
|
|
1563
|
+
elif "object_story_spec" in creative_details:
|
|
1564
|
+
spec = creative_details["object_story_spec"]
|
|
1565
|
+
|
|
1566
|
+
# For link ads
|
|
1567
|
+
if "link_data" in spec:
|
|
1568
|
+
link_data = spec["link_data"]
|
|
1569
|
+
if "image_hash" in link_data:
|
|
1570
|
+
image_hash = link_data["image_hash"]
|
|
1571
|
+
|
|
1572
|
+
# For photo ads
|
|
1573
|
+
elif "photo_data" in spec:
|
|
1574
|
+
photo_data = spec["photo_data"]
|
|
1575
|
+
if "image_hash" in photo_data:
|
|
1576
|
+
image_hash = photo_data["image_hash"]
|
|
1487
1577
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
if "data" in thumbnails_data and len(thumbnails_data["data"]) > 0:
|
|
1495
|
-
for thumbnail in thumbnails_data["data"]:
|
|
1496
|
-
if "uri" in thumbnail:
|
|
1497
|
-
url = thumbnail["uri"]
|
|
1498
|
-
attempt["url"] = url
|
|
1499
|
-
|
|
1500
|
-
# Try to download the image
|
|
1501
|
-
image_bytes = await download_image(url)
|
|
1502
|
-
if image_bytes:
|
|
1503
|
-
attempt["success"] = True
|
|
1504
|
-
attempt["image_size"] = len(image_bytes)
|
|
1505
|
-
|
|
1506
|
-
# Save the image if method 1 didn't already succeed
|
|
1507
|
-
if "success" not in result or not result["success"]:
|
|
1508
|
-
resource_id = f"ad_creative_{ad_id}_method2"
|
|
1509
|
-
resource_uri = f"meta-ads://images/{resource_id}"
|
|
1510
|
-
ad_creative_images[resource_id] = {
|
|
1511
|
-
"data": image_bytes,
|
|
1512
|
-
"mime_type": "image/jpeg",
|
|
1513
|
-
"name": f"Ad Creative for {ad_id} (Method 2)"
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
# Return success with resource info
|
|
1517
|
-
result["resource_uri"] = resource_uri
|
|
1518
|
-
result["success"] = True
|
|
1519
|
-
base64_sample = base64.b64encode(image_bytes[:100]).decode("utf-8") + "..."
|
|
1520
|
-
result["base64_sample"] = base64_sample
|
|
1521
|
-
|
|
1522
|
-
# No need to try more thumbnails if we succeeded
|
|
1523
|
-
break
|
|
1524
|
-
except Exception as e:
|
|
1525
|
-
attempt["error"] = str(e)
|
|
1578
|
+
if not image_hash:
|
|
1579
|
+
return json.dumps({
|
|
1580
|
+
"error": "No image hash found in creative",
|
|
1581
|
+
"creative_details": creative_details
|
|
1582
|
+
}, indent=2)
|
|
1526
1583
|
|
|
1527
|
-
#
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
"
|
|
1584
|
+
# Now get image data from the adimages endpoint
|
|
1585
|
+
image_endpoint = f"act_{account_id}/adimages"
|
|
1586
|
+
image_params = {
|
|
1587
|
+
"hashes": [image_hash]
|
|
1531
1588
|
}
|
|
1532
|
-
result["attempts"].append(attempt)
|
|
1533
1589
|
|
|
1534
|
-
|
|
1535
|
-
# Get ad details with preview link
|
|
1536
|
-
ad_preview_endpoint = f"{ad_id}"
|
|
1537
|
-
ad_preview_params = {
|
|
1538
|
-
"fields": "preview_shareable_link"
|
|
1539
|
-
}
|
|
1540
|
-
ad_preview_data = await make_api_request(ad_preview_endpoint, access_token, ad_preview_params)
|
|
1541
|
-
|
|
1542
|
-
if "preview_shareable_link" in ad_preview_data:
|
|
1543
|
-
preview_link = ad_preview_data["preview_shareable_link"]
|
|
1544
|
-
attempt["preview_link"] = preview_link
|
|
1545
|
-
|
|
1546
|
-
# We can't directly download the preview image, but let's note it for manual inspection
|
|
1547
|
-
attempt["note"] = "Preview link available for manual inspection in browser"
|
|
1548
|
-
|
|
1549
|
-
# This approach doesn't actually download the image, but the link might be useful
|
|
1550
|
-
# for debugging purposes or manual verification
|
|
1551
|
-
except Exception as e:
|
|
1552
|
-
attempt["error"] = str(e)
|
|
1590
|
+
image_data = await make_api_request(image_endpoint, access_token, image_params)
|
|
1553
1591
|
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
return json.dumps(result, indent=2)
|
|
1561
|
-
|
|
1562
|
-
@mcp_server.tool()
|
|
1563
|
-
@meta_api_tool
|
|
1564
|
-
async def get_login_link(access_token: str = None) -> str:
|
|
1565
|
-
"""
|
|
1566
|
-
Get a clickable login link for Meta Ads authentication.
|
|
1592
|
+
if "error" in image_data:
|
|
1593
|
+
return json.dumps({
|
|
1594
|
+
"error": f"Failed to get image data - {image_data['error']}",
|
|
1595
|
+
"hash": image_hash
|
|
1596
|
+
}, indent=2)
|
|
1567
1597
|
|
|
1568
|
-
|
|
1569
|
-
|
|
1598
|
+
if "data" not in image_data or not image_data["data"]:
|
|
1599
|
+
return json.dumps({
|
|
1600
|
+
"error": "No image data returned from API",
|
|
1601
|
+
"hash": image_hash
|
|
1602
|
+
}, indent=2)
|
|
1570
1603
|
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
""
|
|
1574
|
-
# Check if we have a cached token
|
|
1575
|
-
cached_token = auth_manager.get_access_token()
|
|
1576
|
-
token_status = "No token" if not cached_token else "Valid token"
|
|
1604
|
+
# Get the URL from the first image
|
|
1605
|
+
first_image = image_data["data"][0]
|
|
1606
|
+
image_url = first_image.get("url")
|
|
1577
1607
|
|
|
1578
|
-
|
|
1579
|
-
if cached_token and not access_token:
|
|
1608
|
+
if not image_url:
|
|
1580
1609
|
return json.dumps({
|
|
1581
|
-
"
|
|
1582
|
-
"
|
|
1583
|
-
"token_preview": cached_token[:10] + "...",
|
|
1584
|
-
"created_at": auth_manager.token_info.created_at,
|
|
1585
|
-
"expires_in": auth_manager.token_info.expires_in
|
|
1610
|
+
"error": "No image URL found in API response",
|
|
1611
|
+
"api_response": image_data
|
|
1586
1612
|
}, indent=2)
|
|
1587
1613
|
|
|
1588
|
-
#
|
|
1589
|
-
|
|
1590
|
-
port = start_callback_server()
|
|
1591
|
-
|
|
1592
|
-
# Generate direct login URL
|
|
1593
|
-
auth_manager.redirect_uri = f"http://localhost:{port}/callback" # Ensure port is set correctly
|
|
1594
|
-
login_url = auth_manager.get_auth_url()
|
|
1595
|
-
|
|
1596
|
-
# Return a special format that helps the LLM format the response properly
|
|
1597
|
-
response = {
|
|
1598
|
-
"login_url": login_url,
|
|
1599
|
-
"token_status": token_status,
|
|
1600
|
-
"server_status": f"Callback server running on port {port}",
|
|
1601
|
-
"markdown_link": f"[Click here to authenticate with Meta Ads]({login_url})",
|
|
1602
|
-
"message": "IMPORTANT: Please use the Markdown link format in your response to allow the user to click it.",
|
|
1603
|
-
"instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
|
|
1604
|
-
"note": "After authenticating, the token will be automatically saved."
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
# Wait a moment to ensure the server is fully started
|
|
1608
|
-
await asyncio.sleep(1)
|
|
1614
|
+
# Try to save the image by directly downloading it
|
|
1615
|
+
results["image_url"] = image_url
|
|
1609
1616
|
|
|
1610
|
-
|
|
1617
|
+
try:
|
|
1618
|
+
# Try multiple download methods
|
|
1619
|
+
image_bytes = await try_multiple_download_methods(image_url)
|
|
1620
|
+
|
|
1621
|
+
if not image_bytes:
|
|
1622
|
+
return json.dumps({
|
|
1623
|
+
"error": "Failed to download image from URL provided by API",
|
|
1624
|
+
"image_url": image_url,
|
|
1625
|
+
"suggestion": "Try using the debug_image_download tool for more details"
|
|
1626
|
+
}, indent=2)
|
|
1627
|
+
|
|
1628
|
+
# Create a resource ID for this image
|
|
1629
|
+
resource_id = f"ad_{ad_id}_{int(time.time())}"
|
|
1630
|
+
|
|
1631
|
+
# Store the image
|
|
1632
|
+
img = PILImage.open(io.BytesIO(image_bytes))
|
|
1633
|
+
mime_type = f"image/{img.format.lower()}" if img.format else "image/jpeg"
|
|
1634
|
+
|
|
1635
|
+
# Save to our global dictionary
|
|
1636
|
+
ad_creative_images[resource_id] = {
|
|
1637
|
+
"data": image_bytes,
|
|
1638
|
+
"mime_type": mime_type,
|
|
1639
|
+
"name": f"Ad {ad_id} Image",
|
|
1640
|
+
"width": img.width,
|
|
1641
|
+
"height": img.height,
|
|
1642
|
+
"format": img.format
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
# Return the success result with resource info
|
|
1646
|
+
return json.dumps({
|
|
1647
|
+
"success": True,
|
|
1648
|
+
"message": "Successfully saved image",
|
|
1649
|
+
"resource_id": resource_id,
|
|
1650
|
+
"resource_uri": f"meta-ads://images/{resource_id}",
|
|
1651
|
+
"image_details": {
|
|
1652
|
+
"width": img.width,
|
|
1653
|
+
"height": img.height,
|
|
1654
|
+
"format": img.format,
|
|
1655
|
+
"size_bytes": len(image_bytes)
|
|
1656
|
+
}
|
|
1657
|
+
}, indent=2)
|
|
1658
|
+
|
|
1659
|
+
except Exception as e:
|
|
1660
|
+
return json.dumps({
|
|
1661
|
+
"error": f"Error saving image: {str(e)}",
|
|
1662
|
+
"image_url": image_url
|
|
1663
|
+
}, indent=2)
|
|
1611
1664
|
|
|
1612
1665
|
# Helper function to start the login flow
|
|
1613
1666
|
def login():
|
|
@@ -1616,6 +1669,16 @@ def login():
|
|
|
1616
1669
|
"""
|
|
1617
1670
|
print("Starting Meta Ads authentication flow...")
|
|
1618
1671
|
|
|
1672
|
+
# Ensure auth_manager has the current app ID from config
|
|
1673
|
+
current_app_id = meta_config.get_app_id()
|
|
1674
|
+
if not current_app_id:
|
|
1675
|
+
print("Error: No Meta App ID available. Authentication will fail.")
|
|
1676
|
+
print("Please provide an app ID using --app-id or via META_APP_ID environment variable.")
|
|
1677
|
+
return
|
|
1678
|
+
|
|
1679
|
+
auth_manager.app_id = current_app_id
|
|
1680
|
+
print(f"Using Meta App ID from config: {current_app_id}")
|
|
1681
|
+
|
|
1619
1682
|
try:
|
|
1620
1683
|
# Start the callback server first
|
|
1621
1684
|
port = start_callback_server()
|
|
@@ -1767,6 +1830,12 @@ def start_callback_server():
|
|
|
1767
1830
|
|
|
1768
1831
|
# Update auth manager's redirect URI with new port
|
|
1769
1832
|
auth_manager.redirect_uri = f"http://localhost:{port}/callback"
|
|
1833
|
+
|
|
1834
|
+
# Always make sure auth_manager has the current app ID from config
|
|
1835
|
+
current_app_id = meta_config.get_app_id()
|
|
1836
|
+
if current_app_id:
|
|
1837
|
+
auth_manager.app_id = current_app_id
|
|
1838
|
+
|
|
1770
1839
|
callback_server_port = port
|
|
1771
1840
|
|
|
1772
1841
|
try:
|
|
@@ -1834,9 +1903,17 @@ def login_cli():
|
|
|
1834
1903
|
|
|
1835
1904
|
args = parser.parse_args()
|
|
1836
1905
|
|
|
1837
|
-
# Update app ID if provided
|
|
1906
|
+
# Update app ID if provided via CLI
|
|
1838
1907
|
if args.app_id:
|
|
1839
|
-
|
|
1908
|
+
meta_config.set_app_id(args.app_id)
|
|
1909
|
+
else:
|
|
1910
|
+
# Use existing config or environment variable
|
|
1911
|
+
if not meta_config.is_configured():
|
|
1912
|
+
print("Error: No Meta App ID provided. Please provide using --app-id or META_APP_ID environment variable.")
|
|
1913
|
+
return 1
|
|
1914
|
+
|
|
1915
|
+
# Update auth_manager with app ID from config
|
|
1916
|
+
auth_manager.app_id = meta_config.get_app_id()
|
|
1840
1917
|
|
|
1841
1918
|
if args.force_login:
|
|
1842
1919
|
# Clear existing token
|
|
@@ -1849,7 +1926,7 @@ def login_cli():
|
|
|
1849
1926
|
|
|
1850
1927
|
def main():
|
|
1851
1928
|
"""
|
|
1852
|
-
Main entry point for the Meta Ads MCP
|
|
1929
|
+
Main entry point for the Meta Ads MCP Server.
|
|
1853
1930
|
This function handles command line arguments and runs the server.
|
|
1854
1931
|
"""
|
|
1855
1932
|
# Set up command line arguments
|
|
@@ -1859,12 +1936,22 @@ def main():
|
|
|
1859
1936
|
|
|
1860
1937
|
args = parser.parse_args()
|
|
1861
1938
|
|
|
1862
|
-
# Update app ID if provided
|
|
1939
|
+
# Update app ID if provided via CLI (highest priority)
|
|
1863
1940
|
if args.app_id:
|
|
1864
|
-
|
|
1941
|
+
meta_config.set_app_id(args.app_id)
|
|
1942
|
+
|
|
1943
|
+
# Ensure auth_manager has the current app ID
|
|
1944
|
+
app_id = get_current_app_id()
|
|
1945
|
+
if app_id:
|
|
1946
|
+
auth_manager.app_id = app_id
|
|
1947
|
+
else:
|
|
1948
|
+
print("Warning: No Meta App ID provided. Authentication will fail.")
|
|
1865
1949
|
|
|
1866
1950
|
# Handle login command
|
|
1867
1951
|
if args.login:
|
|
1952
|
+
if not meta_config.is_configured():
|
|
1953
|
+
print("Error: Cannot login without a Meta App ID. Please provide using --app-id or META_APP_ID environment variable.")
|
|
1954
|
+
return 1
|
|
1868
1955
|
login()
|
|
1869
1956
|
else:
|
|
1870
1957
|
# Initialize and run the server
|