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