meta-ads-mcp 0.4.3__tar.gz → 0.4.4__tar.gz
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-0.4.3 → meta_ads_mcp-0.4.4}/PKG-INFO +1 -1
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/adsets.py +24 -69
- meta_ads_mcp-0.4.4/meta_ads_mcp/core/callback_server.py +257 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/pyproject.toml +1 -1
- meta_ads_mcp-0.4.3/meta_ads_mcp/core/callback_server.py +0 -1021
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/.gitignore +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/Dockerfile +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/LICENSE +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/README.md +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/RELEASE.md +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/examples/README.md +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/future_improvements.md +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/accounts.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/ads.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/auth.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/duplication.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/meta_ads_mcp/core/utils.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/requirements.txt +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/setup.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/smithery.yaml +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/README.md +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/test_duplication.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/test_duplication_regression.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.4.3 → meta_ads_mcp-0.4.4}/tests/test_openai.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meta-ads-mcp
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: Model Context Protocol (MCP) plugin for interacting with Meta Ads API
|
|
5
5
|
Project-URL: Homepage, https://github.com/pipeboard-co/meta-ads-mcp
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/pipeboard-co/meta-ads-mcp/issues
|
|
@@ -5,9 +5,6 @@ from typing import Optional, Dict, Any, List
|
|
|
5
5
|
from .api import meta_api_tool, make_api_request
|
|
6
6
|
from .accounts import get_ad_accounts
|
|
7
7
|
from .server import mcp_server
|
|
8
|
-
import asyncio
|
|
9
|
-
from .callback_server import start_callback_server, shutdown_callback_server, update_confirmation
|
|
10
|
-
import urllib.parse
|
|
11
8
|
|
|
12
9
|
|
|
13
10
|
@mcp_server.tool()
|
|
@@ -211,94 +208,52 @@ async def update_adset(adset_id: str, frequency_control_specs: List[Dict[str, An
|
|
|
211
208
|
bid_strategy: Bid strategy (e.g., 'LOWEST_COST_WITH_BID_CAP')
|
|
212
209
|
bid_amount: Bid amount in account currency (in cents for USD)
|
|
213
210
|
status: Update ad set status (ACTIVE, PAUSED, etc.)
|
|
214
|
-
targeting:
|
|
215
|
-
(e.g. {"targeting_automation":{"advantage_audience":1}})
|
|
211
|
+
targeting: Complete targeting specifications (will replace existing targeting)
|
|
212
|
+
(e.g. {"targeting_automation":{"advantage_audience":1}, "geo_locations": {"countries": ["US"]}})
|
|
216
213
|
optimization_goal: Conversion optimization goal (e.g., 'LINK_CLICKS', 'CONVERSIONS', 'APP_INSTALLS', etc.)
|
|
217
214
|
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
218
215
|
"""
|
|
219
216
|
if not adset_id:
|
|
220
217
|
return json.dumps({"error": "No ad set ID provided"}, indent=2)
|
|
221
218
|
|
|
222
|
-
|
|
219
|
+
params = {}
|
|
223
220
|
|
|
224
221
|
if frequency_control_specs is not None:
|
|
225
|
-
|
|
222
|
+
params['frequency_control_specs'] = frequency_control_specs
|
|
226
223
|
|
|
227
224
|
if bid_strategy is not None:
|
|
228
|
-
|
|
225
|
+
params['bid_strategy'] = bid_strategy
|
|
229
226
|
|
|
230
227
|
if bid_amount is not None:
|
|
231
|
-
|
|
228
|
+
params['bid_amount'] = str(bid_amount)
|
|
232
229
|
|
|
233
230
|
if status is not None:
|
|
234
|
-
|
|
231
|
+
params['status'] = status
|
|
235
232
|
|
|
236
233
|
if optimization_goal is not None:
|
|
237
|
-
|
|
234
|
+
params['optimization_goal'] = optimization_goal
|
|
238
235
|
|
|
239
236
|
if targeting is not None:
|
|
240
|
-
#
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
# Check if the current ad set has targeting information
|
|
245
|
-
current_targeting = current_details.get('targeting', {})
|
|
246
|
-
|
|
247
|
-
if 'targeting_automation' in targeting:
|
|
248
|
-
# Only update targeting_automation while preserving other targeting settings
|
|
249
|
-
if current_targeting:
|
|
250
|
-
merged_targeting = current_targeting.copy()
|
|
251
|
-
merged_targeting['targeting_automation'] = targeting['targeting_automation']
|
|
252
|
-
changes['targeting'] = merged_targeting
|
|
253
|
-
else:
|
|
254
|
-
# If there's no existing targeting, we need to create a basic one
|
|
255
|
-
# Meta requires at least a geo_locations setting
|
|
256
|
-
basic_targeting = {
|
|
257
|
-
'targeting_automation': targeting['targeting_automation'],
|
|
258
|
-
'geo_locations': {'countries': ['US']} # Using US as default location
|
|
259
|
-
}
|
|
260
|
-
changes['targeting'] = basic_targeting
|
|
237
|
+
# Ensure proper JSON encoding for targeting
|
|
238
|
+
if isinstance(targeting, dict):
|
|
239
|
+
params['targeting'] = json.dumps(targeting)
|
|
261
240
|
else:
|
|
262
|
-
|
|
263
|
-
changes['targeting'] = targeting
|
|
241
|
+
params['targeting'] = targeting # Already a string
|
|
264
242
|
|
|
265
|
-
if not
|
|
243
|
+
if not params:
|
|
266
244
|
return json.dumps({"error": "No update parameters provided"}, indent=2)
|
|
245
|
+
|
|
246
|
+
endpoint = f"{adset_id}"
|
|
267
247
|
|
|
268
|
-
# Get current ad set details for comparison
|
|
269
|
-
current_details_json = await get_adset_details(adset_id=adset_id, access_token=access_token)
|
|
270
|
-
current_details = json.loads(current_details_json)
|
|
271
|
-
|
|
272
|
-
# Start the callback server if not already running
|
|
273
248
|
try:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
changes_json = json.dumps(changes)
|
|
278
|
-
encoded_changes = urllib.parse.quote(changes_json)
|
|
279
|
-
confirmation_url = f"http://localhost:{port}/confirm-update?adset_id={adset_id}&token={access_token}&changes={encoded_changes}"
|
|
249
|
+
# Use POST method for updates as per Meta API documentation
|
|
250
|
+
data = await make_api_request(endpoint, access_token, params, method="POST")
|
|
251
|
+
return json.dumps(data, indent=2)
|
|
280
252
|
except Exception as e:
|
|
253
|
+
error_msg = str(e)
|
|
254
|
+
# Include adset_id in error for better context
|
|
281
255
|
return json.dumps({
|
|
282
|
-
"error": "
|
|
283
|
-
"
|
|
284
|
-
"
|
|
285
|
-
|
|
286
|
-
"proposed_changes": changes
|
|
287
|
-
}, indent=2)
|
|
288
|
-
|
|
289
|
-
# Reset the update confirmation
|
|
290
|
-
update_confirmation.clear()
|
|
291
|
-
update_confirmation.update({"approved": False})
|
|
292
|
-
|
|
293
|
-
# Return the confirmation link
|
|
294
|
-
response = {
|
|
295
|
-
"message": "Please confirm the ad set update",
|
|
296
|
-
"confirmation_url": confirmation_url,
|
|
297
|
-
"markdown_link": f"[Click here to confirm ad set update]({confirmation_url})",
|
|
298
|
-
"current_details": current_details,
|
|
299
|
-
"proposed_changes": changes,
|
|
300
|
-
"instructions_for_llm": "You must present this link as clickable Markdown to the user using the markdown_link format provided.",
|
|
301
|
-
"note": "Click the link to confirm and apply your ad set updates. Refresh the browser page if it doesn't load immediately."
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return json.dumps(response, indent=2)
|
|
256
|
+
"error": f"Failed to update ad set {adset_id}",
|
|
257
|
+
"details": error_msg,
|
|
258
|
+
"params_sent": params
|
|
259
|
+
}, indent=2)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Callback server for Meta Ads API authentication."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import socket
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import webbrowser
|
|
9
|
+
import os
|
|
10
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
11
|
+
from urllib.parse import urlparse, parse_qs, quote
|
|
12
|
+
from typing import Dict, Any, Optional
|
|
13
|
+
|
|
14
|
+
from .utils import logger
|
|
15
|
+
|
|
16
|
+
# Global token container for communication between threads
|
|
17
|
+
token_container = {"token": None, "expires_in": None, "user_id": None}
|
|
18
|
+
|
|
19
|
+
# Global variables for server thread and state
|
|
20
|
+
callback_server_thread = None
|
|
21
|
+
callback_server_lock = threading.Lock()
|
|
22
|
+
callback_server_running = False
|
|
23
|
+
callback_server_port = None
|
|
24
|
+
callback_server_instance = None
|
|
25
|
+
server_shutdown_timer = None
|
|
26
|
+
|
|
27
|
+
# Timeout in seconds before shutting down the callback server
|
|
28
|
+
CALLBACK_SERVER_TIMEOUT = 180 # 3 minutes timeout
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
32
|
+
def do_GET(self):
|
|
33
|
+
try:
|
|
34
|
+
# Print path for debugging
|
|
35
|
+
print(f"Callback server received request: {self.path}")
|
|
36
|
+
|
|
37
|
+
if self.path.startswith("/callback"):
|
|
38
|
+
self._handle_oauth_callback()
|
|
39
|
+
elif self.path.startswith("/token"):
|
|
40
|
+
self._handle_token()
|
|
41
|
+
else:
|
|
42
|
+
# If no matching path, return a 404 error
|
|
43
|
+
self.send_response(404)
|
|
44
|
+
self.end_headers()
|
|
45
|
+
except Exception as e:
|
|
46
|
+
print(f"Error processing request: {e}")
|
|
47
|
+
self.send_response(500)
|
|
48
|
+
self.end_headers()
|
|
49
|
+
|
|
50
|
+
def _handle_oauth_callback(self):
|
|
51
|
+
"""Handle OAuth callback after user authorization"""
|
|
52
|
+
# Check if we're being redirected from Facebook with an authorization code
|
|
53
|
+
parsed_url = urlparse(self.path)
|
|
54
|
+
params = parse_qs(parsed_url.query)
|
|
55
|
+
|
|
56
|
+
# Check for code parameter
|
|
57
|
+
code = params.get('code', [None])[0]
|
|
58
|
+
state = params.get('state', [None])[0]
|
|
59
|
+
error = params.get('error', [None])[0]
|
|
60
|
+
|
|
61
|
+
# Send 200 OK response with a simple HTML page
|
|
62
|
+
self.send_response(200)
|
|
63
|
+
self.send_header("Content-type", "text/html")
|
|
64
|
+
self.end_headers()
|
|
65
|
+
|
|
66
|
+
if error:
|
|
67
|
+
# User denied access or other error occurred
|
|
68
|
+
html = f"""
|
|
69
|
+
<html>
|
|
70
|
+
<head><title>Authorization Failed</title></head>
|
|
71
|
+
<body>
|
|
72
|
+
<h1>Authorization Failed</h1>
|
|
73
|
+
<p>Error: {error}</p>
|
|
74
|
+
<p>The authorization was cancelled or failed. You can close this window.</p>
|
|
75
|
+
</body>
|
|
76
|
+
</html>
|
|
77
|
+
"""
|
|
78
|
+
logger.error(f"OAuth authorization failed: {error}")
|
|
79
|
+
elif code:
|
|
80
|
+
# Success case - we have the authorization code
|
|
81
|
+
logger.info(f"Received authorization code: {code[:10]}...")
|
|
82
|
+
|
|
83
|
+
# Store the authorization code temporarily
|
|
84
|
+
# The auth module will exchange this for an access token
|
|
85
|
+
token_container.update({
|
|
86
|
+
"auth_code": code,
|
|
87
|
+
"state": state,
|
|
88
|
+
"timestamp": asyncio.get_event_loop().time()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
html = """
|
|
92
|
+
<html>
|
|
93
|
+
<head><title>Authorization Successful</title></head>
|
|
94
|
+
<body>
|
|
95
|
+
<h1>✅ Authorization Successful!</h1>
|
|
96
|
+
<p>You have successfully authorized the Meta Ads MCP application.</p>
|
|
97
|
+
<p>You can now close this window and return to your application.</p>
|
|
98
|
+
<script>
|
|
99
|
+
// Try to close the window automatically after 2 seconds
|
|
100
|
+
setTimeout(function() {
|
|
101
|
+
window.close();
|
|
102
|
+
}, 2000);
|
|
103
|
+
</script>
|
|
104
|
+
</body>
|
|
105
|
+
</html>
|
|
106
|
+
"""
|
|
107
|
+
logger.info("OAuth authorization successful")
|
|
108
|
+
else:
|
|
109
|
+
# No code or error - something unexpected happened
|
|
110
|
+
html = """
|
|
111
|
+
<html>
|
|
112
|
+
<head><title>Unexpected Response</title></head>
|
|
113
|
+
<body>
|
|
114
|
+
<h1>Unexpected Response</h1>
|
|
115
|
+
<p>No authorization code or error received. Please try again.</p>
|
|
116
|
+
</body>
|
|
117
|
+
</html>
|
|
118
|
+
"""
|
|
119
|
+
logger.warning("OAuth callback received without code or error")
|
|
120
|
+
|
|
121
|
+
self.wfile.write(html.encode())
|
|
122
|
+
|
|
123
|
+
def _handle_token(self):
|
|
124
|
+
"""Handle token endpoint for retrieving stored token data"""
|
|
125
|
+
# This endpoint allows other parts of the application to retrieve
|
|
126
|
+
# token information from the callback server
|
|
127
|
+
|
|
128
|
+
self.send_response(200)
|
|
129
|
+
self.send_header("Content-type", "application/json")
|
|
130
|
+
self.end_headers()
|
|
131
|
+
|
|
132
|
+
# Return current token container contents
|
|
133
|
+
response_data = {
|
|
134
|
+
"status": "success",
|
|
135
|
+
"data": token_container
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
self.wfile.write(json.dumps(response_data).encode())
|
|
139
|
+
|
|
140
|
+
# The actual token processing is now handled by the auth module
|
|
141
|
+
# that imports this module and accesses token_container
|
|
142
|
+
|
|
143
|
+
# Silence server logs
|
|
144
|
+
def log_message(self, format, *args):
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def shutdown_callback_server():
|
|
149
|
+
"""
|
|
150
|
+
Shutdown the callback server if it's running
|
|
151
|
+
"""
|
|
152
|
+
global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
|
|
153
|
+
|
|
154
|
+
with callback_server_lock:
|
|
155
|
+
if not callback_server_running:
|
|
156
|
+
print("Callback server is not running")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
if server_shutdown_timer is not None:
|
|
160
|
+
server_shutdown_timer.cancel()
|
|
161
|
+
server_shutdown_timer = None
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
if callback_server_instance:
|
|
165
|
+
print("Shutting down callback server...")
|
|
166
|
+
callback_server_instance.shutdown()
|
|
167
|
+
callback_server_instance.server_close()
|
|
168
|
+
print("Callback server shut down successfully")
|
|
169
|
+
|
|
170
|
+
if callback_server_thread and callback_server_thread.is_alive():
|
|
171
|
+
callback_server_thread.join(timeout=5)
|
|
172
|
+
if callback_server_thread.is_alive():
|
|
173
|
+
print("Warning: Callback server thread did not shut down cleanly")
|
|
174
|
+
except Exception as e:
|
|
175
|
+
print(f"Error during callback server shutdown: {e}")
|
|
176
|
+
finally:
|
|
177
|
+
callback_server_running = False
|
|
178
|
+
callback_server_thread = None
|
|
179
|
+
callback_server_port = None
|
|
180
|
+
callback_server_instance = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def start_callback_server() -> int:
|
|
184
|
+
"""
|
|
185
|
+
Start the callback server and return the port number it's running on.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
int: Port number the server is listening on
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
Exception: If the server fails to start
|
|
192
|
+
"""
|
|
193
|
+
global callback_server_thread, callback_server_running, callback_server_port, callback_server_instance, server_shutdown_timer
|
|
194
|
+
|
|
195
|
+
# Check if callback server is disabled
|
|
196
|
+
if os.environ.get("META_ADS_DISABLE_CALLBACK_SERVER"):
|
|
197
|
+
raise Exception("Callback server is disabled via META_ADS_DISABLE_CALLBACK_SERVER environment variable")
|
|
198
|
+
|
|
199
|
+
with callback_server_lock:
|
|
200
|
+
if callback_server_running:
|
|
201
|
+
print(f"Callback server already running on port {callback_server_port}")
|
|
202
|
+
return callback_server_port
|
|
203
|
+
|
|
204
|
+
# Find an available port
|
|
205
|
+
port = 8080
|
|
206
|
+
max_attempts = 10
|
|
207
|
+
for attempt in range(max_attempts):
|
|
208
|
+
try:
|
|
209
|
+
# Test if port is available
|
|
210
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
211
|
+
s.bind(('localhost', port))
|
|
212
|
+
break
|
|
213
|
+
except OSError:
|
|
214
|
+
port += 1
|
|
215
|
+
else:
|
|
216
|
+
raise Exception(f"Could not find an available port after {max_attempts} attempts")
|
|
217
|
+
|
|
218
|
+
callback_server_port = port
|
|
219
|
+
|
|
220
|
+
# Start the server in a separate thread
|
|
221
|
+
callback_server_thread = threading.Thread(target=server_thread, daemon=True)
|
|
222
|
+
callback_server_thread.start()
|
|
223
|
+
|
|
224
|
+
# Wait a moment for the server to start
|
|
225
|
+
import time
|
|
226
|
+
time.sleep(0.5)
|
|
227
|
+
|
|
228
|
+
if not callback_server_running:
|
|
229
|
+
raise Exception("Failed to start callback server")
|
|
230
|
+
|
|
231
|
+
# Set up automatic shutdown timer
|
|
232
|
+
def auto_shutdown():
|
|
233
|
+
print(f"Callback server auto-shutdown after {CALLBACK_SERVER_TIMEOUT} seconds")
|
|
234
|
+
shutdown_callback_server()
|
|
235
|
+
|
|
236
|
+
server_shutdown_timer = threading.Timer(CALLBACK_SERVER_TIMEOUT, auto_shutdown)
|
|
237
|
+
server_shutdown_timer.start()
|
|
238
|
+
|
|
239
|
+
print(f"Callback server started on http://localhost:{port}")
|
|
240
|
+
return port
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def server_thread():
|
|
244
|
+
"""Thread function to run the callback server"""
|
|
245
|
+
global callback_server_running, callback_server_instance
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
callback_server_instance = HTTPServer(('localhost', callback_server_port), CallbackHandler)
|
|
249
|
+
callback_server_running = True
|
|
250
|
+
print(f"Callback server thread started on port {callback_server_port}")
|
|
251
|
+
callback_server_instance.serve_forever()
|
|
252
|
+
except Exception as e:
|
|
253
|
+
print(f"Callback server error: {e}")
|
|
254
|
+
callback_server_running = False
|
|
255
|
+
finally:
|
|
256
|
+
print("Callback server thread finished")
|
|
257
|
+
callback_server_running = False
|