meta-ads-mcp-python 1.0.79__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,207 @@
1
+ """Authentication-specific functionality for Meta Ads API.
2
+
3
+ The Meta Ads MCP server supports three authentication modes:
4
+
5
+ 1. **Development/Local Mode** (default)
6
+ - Uses local callback server on localhost:8080+ for OAuth redirect
7
+ - Requires META_ADS_DISABLE_CALLBACK_SERVER to NOT be set
8
+ - Best for local development and testing
9
+
10
+ 2. **Production with API Token**
11
+ - Uses PIPEBOARD_API_TOKEN for server-to-server authentication
12
+ - Bypasses OAuth flow entirely
13
+ - Best for server deployments with pre-configured tokens
14
+
15
+ 3. **Production OAuth Flow** (NEW)
16
+ - Uses Pipeboard OAuth endpoints for dynamic client registration
17
+ - Triggered when META_ADS_DISABLE_CALLBACK_SERVER is set but no PIPEBOARD_API_TOKEN
18
+ - Supports MCP clients that implement OAuth 2.0 discovery
19
+
20
+ Environment Variables:
21
+ - PIPEBOARD_API_TOKEN: Enables mode 2 (token-based auth)
22
+ - META_ADS_DISABLE_CALLBACK_SERVER: Disables local server, enables mode 3
23
+ - META_ACCESS_TOKEN: Direct Meta token (fallback)
24
+ - META_ADS_DISABLE_LOGIN_LINK: Hard-disables the get_login_link tool; returns a disabled message
25
+ """
26
+
27
+ import json
28
+ from typing import Optional
29
+ import asyncio
30
+ import os
31
+ from .api import meta_api_tool
32
+ from . import auth
33
+ from .auth import start_callback_server, shutdown_callback_server, auth_manager
34
+ from .server import mcp_server
35
+ from .utils import logger, META_APP_SECRET
36
+ from .pipeboard_auth import pipeboard_auth_manager
37
+
38
+ # Only register the login link tool if not explicitly disabled
39
+ ENABLE_LOGIN_LINK = not bool(os.environ.get("META_ADS_DISABLE_LOGIN_LINK", ""))
40
+
41
+
42
+ async def get_login_link(access_token: Optional[str] = None) -> str:
43
+ """
44
+ Get a clickable login link for Meta Ads authentication.
45
+
46
+ NOTE: This method should only be used if you're using your own Facebook app.
47
+ If using Pipeboard authentication (recommended), set the PIPEBOARD_API_TOKEN
48
+ environment variable instead (token obtainable via https://pipeboard.co).
49
+
50
+ Args:
51
+ access_token: Meta API access token (optional - will use cached token if not provided)
52
+
53
+ Returns:
54
+ A clickable resource link for Meta authentication
55
+ """
56
+ # Check if we're using pipeboard authentication
57
+ using_pipeboard = bool(os.environ.get("PIPEBOARD_API_TOKEN", ""))
58
+ callback_server_disabled = bool(os.environ.get("META_ADS_DISABLE_CALLBACK_SERVER", ""))
59
+
60
+ if using_pipeboard:
61
+ # Pipeboard token-based authentication
62
+ try:
63
+ logger.info("Using Pipeboard token-based authentication")
64
+
65
+ # If an access token was provided, this is likely a test - return success
66
+ if access_token:
67
+ return json.dumps({
68
+ "message": " Authentication Token Provided",
69
+ "status": "Using provided access token for authentication",
70
+ "token_info": f"Token preview: {access_token[:10]}...",
71
+ "authentication_method": "manual_token",
72
+ "ready_to_use": "You can now use all Meta Ads MCP tools and commands."
73
+ }, indent=2)
74
+
75
+ # Check if Pipeboard token is working
76
+ token = pipeboard_auth_manager.get_access_token()
77
+ if token:
78
+ return json.dumps({
79
+ "message": " Already Authenticated",
80
+ "status": "You're successfully authenticated with Meta Ads via Pipeboard!",
81
+ "token_info": f"Token preview: {token[:10]}...",
82
+ "authentication_method": "pipeboard_token",
83
+ "ready_to_use": "You can now use all Meta Ads MCP tools and commands."
84
+ }, indent=2)
85
+
86
+ # Start Pipeboard auth flow
87
+ auth_data = pipeboard_auth_manager.initiate_auth_flow()
88
+ login_url = auth_data.get('loginUrl')
89
+
90
+ if login_url:
91
+ return json.dumps({
92
+ "message": " Click to Authenticate",
93
+ "login_url": login_url,
94
+ "markdown_link": f"[ Authenticate with Meta Ads]({login_url})",
95
+ "instructions": "Click the link above to complete authentication with Meta Ads.",
96
+ "authentication_method": "pipeboard_oauth",
97
+ "what_happens_next": "After clicking, you'll be redirected to Meta's authentication page. Once completed, your token will be automatically saved.",
98
+ "token_duration": "Your token will be valid for approximately 60 days."
99
+ }, indent=2)
100
+ else:
101
+ return json.dumps({
102
+ "message": " Authentication Error",
103
+ "error": "Could not generate authentication URL from Pipeboard",
104
+ "troubleshooting": [
105
+ "Check that your PIPEBOARD_API_TOKEN is valid",
106
+ "Ensure the Pipeboard service is accessible",
107
+ "Try again in a few moments"
108
+ ],
109
+ "authentication_method": "pipeboard_oauth_failed"
110
+ }, indent=2)
111
+
112
+ except Exception as e:
113
+ logger.error(f"Error initiating Pipeboard auth flow: {e}")
114
+ return json.dumps({
115
+ "message": " Pipeboard Authentication Error",
116
+ "error": f"Failed to initiate Pipeboard authentication: {str(e)}",
117
+ "troubleshooting": [
118
+ " Check that PIPEBOARD_API_TOKEN environment variable is set correctly",
119
+ " Verify that pipeboard.co is accessible from your network",
120
+ " Try refreshing your Pipeboard API token",
121
+ " Wait a moment and try again"
122
+ ],
123
+ "get_help": "Contact support if the issue persists",
124
+ "authentication_method": "pipeboard_error"
125
+ }, indent=2)
126
+ elif callback_server_disabled:
127
+ # Production OAuth flow - use Pipeboard OAuth endpoints directly
128
+ logger.info("Production OAuth flow - using Pipeboard OAuth endpoints")
129
+
130
+ return json.dumps({
131
+ "message": " Authentication Required",
132
+ "instructions": "Please sign in to your Pipeboard account to authenticate with Meta Ads.",
133
+ "sign_in_url": "https://pipeboard.co/auth/signin",
134
+ "markdown_link": "[ Sign in to Pipeboard](https://pipeboard.co/auth/signin)",
135
+ "what_to_do": "Click the link above to sign in to your Pipeboard account and complete authentication.",
136
+ "authentication_method": "production_oauth"
137
+ }, indent=2)
138
+ else:
139
+ # Original Meta authentication flow (development/local)
140
+ # Check if we have a cached token
141
+ cached_token = auth_manager.get_access_token()
142
+ token_status = "No token" if not cached_token else "Valid token"
143
+
144
+ # If we already have a valid token and none was provided, just return success
145
+ if cached_token and not access_token:
146
+ logger.info("get_login_link called with existing valid token")
147
+ return json.dumps({
148
+ "message": " Already Authenticated",
149
+ "status": "You're successfully authenticated with Meta Ads!",
150
+ "token_info": f"Token preview: {cached_token[:10]}...",
151
+ "created_at": auth_manager.token_info.created_at if hasattr(auth_manager, "token_info") else None,
152
+ "expires_in": auth_manager.token_info.expires_in if hasattr(auth_manager, "token_info") else None,
153
+ "authentication_method": "meta_oauth",
154
+ "ready_to_use": "You can now use all Meta Ads MCP tools and commands."
155
+ }, indent=2)
156
+
157
+ # IMPORTANT: Start the callback server first by calling our helper function
158
+ # This ensures the server is ready before we provide the URL to the user
159
+ logger.info("Starting callback server for authentication")
160
+ try:
161
+ port = start_callback_server()
162
+ logger.info(f"Callback server started on port {port}")
163
+
164
+ # Generate direct login URL
165
+ auth_manager.redirect_uri = f"http://localhost:{port}/callback" # Ensure port is set correctly
166
+ logger.info(f"Setting redirect URI to {auth_manager.redirect_uri}")
167
+ login_url = auth_manager.get_auth_url()
168
+ logger.info(f"Generated login URL: {login_url}")
169
+ except Exception as e:
170
+ logger.error(f"Failed to start callback server: {e}")
171
+ return json.dumps({
172
+ "message": " Local Authentication Unavailable",
173
+ "error": "Cannot start local callback server for authentication",
174
+ "reason": str(e),
175
+ "solutions": [
176
+ " Use Pipeboard authentication: Set PIPEBOARD_API_TOKEN environment variable",
177
+ " Use direct token: Set META_ACCESS_TOKEN environment variable",
178
+ " Check if another service is using the required ports"
179
+ ],
180
+ "authentication_method": "meta_oauth_disabled"
181
+ }, indent=2)
182
+
183
+ # Check if we can exchange for long-lived tokens
184
+ token_exchange_supported = bool(META_APP_SECRET)
185
+ token_duration = "60 days" if token_exchange_supported else "1-2 hours"
186
+
187
+ # Return a special format that helps the LLM format the response properly
188
+ response = {
189
+ "message": " Click to Authenticate",
190
+ "login_url": login_url,
191
+ "markdown_link": f"[ Authenticate with Meta Ads]({login_url})",
192
+ "instructions": "Click the link above to authenticate with Meta Ads.",
193
+ "server_info": f"Local callback server running on port {port}",
194
+ "token_duration": f"Your token will be valid for approximately {token_duration}",
195
+ "authentication_method": "meta_oauth",
196
+ "what_happens_next": "After clicking, you'll be redirected to Meta's authentication page. Once completed, your token will be automatically saved.",
197
+ "security_note": "This uses a secure local callback server for development purposes."
198
+ }
199
+
200
+ # Wait a moment to ensure the server is fully started
201
+ await asyncio.sleep(1)
202
+
203
+ return json.dumps(response, indent=2)
204
+
205
+ # Conditionally register as MCP tool only when enabled
206
+ if ENABLE_LOGIN_LINK:
207
+ get_login_link = mcp_server.tool()(get_login_link)
@@ -0,0 +1,70 @@
1
+ """Budget Schedule-related functionality for Meta Ads API."""
2
+
3
+ import json
4
+ from typing import Optional, Dict, Any
5
+
6
+ from .api import meta_api_tool, make_api_request
7
+ from .server import mcp_server
8
+ # Assuming no other specific dependencies from adsets.py are needed for this single function.
9
+ # If other utilities from adsets.py (like get_ad_accounts) were needed, they'd be imported here.
10
+
11
+ @meta_api_tool
12
+ async def create_budget_schedule(
13
+ campaign_id: str,
14
+ budget_value: int,
15
+ budget_value_type: str,
16
+ time_start: int,
17
+ time_end: int,
18
+ access_token: Optional[str] = None
19
+ ) -> str:
20
+ """
21
+ Create a budget schedule for a Meta Ads campaign.
22
+
23
+ Allows scheduling budget increases based on anticipated high-demand periods.
24
+ The times should be provided as Unix timestamps.
25
+
26
+ Args:
27
+ campaign_id: Meta Ads campaign ID.
28
+ budget_value: Amount of budget increase. Interpreted based on budget_value_type.
29
+ budget_value_type: Type of budget value - "ABSOLUTE" or "MULTIPLIER".
30
+ time_start: Unix timestamp for when the high demand period should start.
31
+ time_end: Unix timestamp for when the high demand period should end.
32
+ access_token: Meta API access token (optional - will use cached token if not provided).
33
+
34
+ Returns:
35
+ A JSON string containing the ID of the created budget schedule or an error message.
36
+ """
37
+ if not campaign_id:
38
+ return json.dumps({"error": "Campaign ID is required"}, indent=2)
39
+ if budget_value is None: # Check for None explicitly
40
+ return json.dumps({"error": "Budget value is required"}, indent=2)
41
+ if not budget_value_type:
42
+ return json.dumps({"error": "Budget value type is required"}, indent=2)
43
+ if budget_value_type not in ["ABSOLUTE", "MULTIPLIER"]:
44
+ return json.dumps({"error": "Invalid budget_value_type. Must be ABSOLUTE or MULTIPLIER"}, indent=2)
45
+ if time_start is None: # Check for None explicitly to allow 0
46
+ return json.dumps({"error": "Time start is required"}, indent=2)
47
+ if time_end is None: # Check for None explicitly to allow 0
48
+ return json.dumps({"error": "Time end is required"}, indent=2)
49
+
50
+ endpoint = f"{campaign_id}/budget_schedules"
51
+
52
+ params = {
53
+ "budget_value": budget_value,
54
+ "budget_value_type": budget_value_type,
55
+ "time_start": time_start,
56
+ "time_end": time_end,
57
+ }
58
+
59
+ try:
60
+ data = await make_api_request(endpoint, access_token, params, method="POST")
61
+ return json.dumps(data, indent=2)
62
+ except Exception as e:
63
+ error_msg = str(e)
64
+ # Include details about the error and the parameters sent for easier debugging
65
+ return json.dumps({
66
+ "error": "Failed to create budget schedule",
67
+ "details": error_msg,
68
+ "campaign_id": campaign_id,
69
+ "params_sent": params
70
+ }, indent=2)
@@ -0,0 +1,256 @@
1
+ """Callback server for Meta Ads API authentication."""
2
+
3
+ import threading
4
+ import socket
5
+ import asyncio
6
+ import json
7
+ import webbrowser
8
+ import os
9
+ from http.server import HTTPServer, BaseHTTPRequestHandler
10
+ from urllib.parse import urlparse, parse_qs, quote
11
+ from typing import Dict, Any, Optional
12
+
13
+ from .utils import logger
14
+
15
+ # Global token container for communication between threads
16
+ token_container = {"token": None, "expires_in": None, "user_id": None}
17
+
18
+ # Global variables for server thread and state
19
+ callback_server_thread = None
20
+ callback_server_lock = threading.Lock()
21
+ callback_server_running = False
22
+ callback_server_port = None
23
+ callback_server_instance = None
24
+ server_shutdown_timer = None
25
+
26
+ # Timeout in seconds before shutting down the callback server
27
+ CALLBACK_SERVER_TIMEOUT = 180 # 3 minutes timeout
28
+
29
+
30
+ class CallbackHandler(BaseHTTPRequestHandler):
31
+ def do_GET(self):
32
+ try:
33
+ # Print path for debugging
34
+ print(f"Callback server received request: {self.path}")
35
+
36
+ if self.path.startswith("/callback"):
37
+ self._handle_oauth_callback()
38
+ elif self.path.startswith("/token"):
39
+ self._handle_token()
40
+ else:
41
+ # If no matching path, return a 404 error
42
+ self.send_response(404)
43
+ self.end_headers()
44
+ except Exception as e:
45
+ print(f"Error processing request: {e}")
46
+ self.send_response(500)
47
+ self.end_headers()
48
+
49
+ def _handle_oauth_callback(self):
50
+ """Handle OAuth callback after user authorization"""
51
+ # Check if we're being redirected from Facebook with an authorization code
52
+ parsed_url = urlparse(self.path)
53
+ params = parse_qs(parsed_url.query)
54
+
55
+ # Check for code parameter
56
+ code = params.get('code', [None])[0]
57
+ state = params.get('state', [None])[0]
58
+ error = params.get('error', [None])[0]
59
+
60
+ # Send 200 OK response with a simple HTML page
61
+ self.send_response(200)
62
+ self.send_header("Content-type", "text/html")
63
+ self.end_headers()
64
+
65
+ if error:
66
+ # User denied access or other error occurred
67
+ html = f"""
68
+ <html>
69
+ <head><title>Authorization Failed</title></head>
70
+ <body>
71
+ <h1>Authorization Failed</h1>
72
+ <p>Error: {error}</p>
73
+ <p>The authorization was cancelled or failed. You can close this window.</p>
74
+ </body>
75
+ </html>
76
+ """
77
+ logger.error(f"OAuth authorization failed: {error}")
78
+ elif code:
79
+ # Success case - we have the authorization code
80
+ logger.info(f"Received authorization code: {code[:10]}...")
81
+
82
+ # Store the authorization code temporarily
83
+ # The auth module will exchange this for an access token
84
+ token_container.update({
85
+ "auth_code": code,
86
+ "state": state,
87
+ "timestamp": asyncio.get_event_loop().time()
88
+ })
89
+
90
+ html = """
91
+ <html>
92
+ <head><title>Authorization Successful</title></head>
93
+ <body>
94
+ <h1> Authorization Successful!</h1>
95
+ <p>You have successfully authorized the Meta Ads MCP application.</p>
96
+ <p>You can now close this window and return to your application.</p>
97
+ <script>
98
+ // Try to close the window automatically after 2 seconds
99
+ setTimeout(function() {
100
+ window.close();
101
+ }, 2000);
102
+ </script>
103
+ </body>
104
+ </html>
105
+ """
106
+ logger.info("OAuth authorization successful")
107
+ else:
108
+ # No code or error - something unexpected happened
109
+ html = """
110
+ <html>
111
+ <head><title>Unexpected Response</title></head>
112
+ <body>
113
+ <h1>Unexpected Response</h1>
114
+ <p>No authorization code or error received. Please try again.</p>
115
+ </body>
116
+ </html>
117
+ """
118
+ logger.warning("OAuth callback received without code or error")
119
+
120
+ self.wfile.write(html.encode())
121
+
122
+ def _handle_token(self):
123
+ """Handle token endpoint for retrieving stored token data"""
124
+ # This endpoint allows other parts of the application to retrieve
125
+ # token information from the callback server
126
+
127
+ self.send_response(200)
128
+ self.send_header("Content-type", "application/json")
129
+ self.end_headers()
130
+
131
+ # Return current token container contents
132
+ response_data = {
133
+ "status": "success",
134
+ "data": token_container
135
+ }
136
+
137
+ self.wfile.write(json.dumps(response_data).encode())
138
+
139
+ # The actual token processing is now handled by the auth module
140
+ # that imports this module and accesses token_container
141
+
142
+ # Silence server logs
143
+ def log_message(self, format, *args):
144
+ return
145
+
146
+
147
+ def shutdown_callback_server():
148
+ """
149
+ Shutdown the callback server if it's running
150
+ """
151
+ global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
152
+
153
+ with callback_server_lock:
154
+ if not callback_server_running:
155
+ print("Callback server is not running")
156
+ return
157
+
158
+ if server_shutdown_timer is not None:
159
+ server_shutdown_timer.cancel()
160
+ server_shutdown_timer = None
161
+
162
+ try:
163
+ if callback_server_instance:
164
+ print("Shutting down callback server...")
165
+ callback_server_instance.shutdown()
166
+ callback_server_instance.server_close()
167
+ print("Callback server shut down successfully")
168
+
169
+ if callback_server_thread and callback_server_thread.is_alive():
170
+ callback_server_thread.join(timeout=5)
171
+ if callback_server_thread.is_alive():
172
+ print("Warning: Callback server thread did not shut down cleanly")
173
+ except Exception as e:
174
+ print(f"Error during callback server shutdown: {e}")
175
+ finally:
176
+ callback_server_running = False
177
+ callback_server_thread = None
178
+ callback_server_port = None
179
+ callback_server_instance = None
180
+
181
+
182
+ def start_callback_server() -> int:
183
+ """
184
+ Start the callback server and return the port number it's running on.
185
+
186
+ Returns:
187
+ int: Port number the server is listening on
188
+
189
+ Raises:
190
+ Exception: If the server fails to start
191
+ """
192
+ global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
193
+
194
+ # Check if callback server is disabled
195
+ if os.environ.get("META_ADS_DISABLE_CALLBACK_SERVER"):
196
+ raise Exception("Callback server is disabled via META_ADS_DISABLE_CALLBACK_SERVER environment variable")
197
+
198
+ with callback_server_lock:
199
+ if callback_server_running:
200
+ print(f"Callback server already running on port {callback_server_port}")
201
+ return callback_server_port
202
+
203
+ # Find an available port
204
+ port = 8080
205
+ max_attempts = 10
206
+ for attempt in range(max_attempts):
207
+ try:
208
+ # Test if port is available
209
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
210
+ s.bind(('localhost', port))
211
+ break
212
+ except OSError:
213
+ port += 1
214
+ else:
215
+ raise Exception(f"Could not find an available port after {max_attempts} attempts")
216
+
217
+ callback_server_port = port
218
+
219
+ # Start the server in a separate thread
220
+ callback_server_thread = threading.Thread(target=server_thread, daemon=True)
221
+ callback_server_thread.start()
222
+
223
+ # Wait a moment for the server to start
224
+ import time
225
+ time.sleep(0.5)
226
+
227
+ if not callback_server_running:
228
+ raise Exception("Failed to start callback server")
229
+
230
+ # Set up automatic shutdown timer
231
+ def auto_shutdown():
232
+ print(f"Callback server auto-shutdown after {CALLBACK_SERVER_TIMEOUT} seconds")
233
+ shutdown_callback_server()
234
+
235
+ server_shutdown_timer = threading.Timer(CALLBACK_SERVER_TIMEOUT, auto_shutdown)
236
+ server_shutdown_timer.start()
237
+
238
+ print(f"Callback server started on http://localhost:{port}")
239
+ return port
240
+
241
+
242
+ def server_thread():
243
+ """Thread function to run the callback server"""
244
+ global callback_server_running, callback_server_instance
245
+
246
+ try:
247
+ callback_server_instance = HTTPServer(('localhost', callback_server_port), CallbackHandler)
248
+ callback_server_running = True
249
+ print(f"Callback server thread started on port {callback_server_port}")
250
+ callback_server_instance.serve_forever()
251
+ except Exception as e:
252
+ print(f"Callback server error: {e}")
253
+ callback_server_running = False
254
+ finally:
255
+ print("Callback server thread finished")
256
+ callback_server_running = False