telnyx-mcp-server-fastmcp 0.1.3__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.
- telnyx_mcp_server/__init__.py +0 -0
- telnyx_mcp_server/__main__.py +23 -0
- telnyx_mcp_server/config.py +148 -0
- telnyx_mcp_server/mcp.py +148 -0
- telnyx_mcp_server/server.py +497 -0
- telnyx_mcp_server/telnyx/__init__.py +1 -0
- telnyx_mcp_server/telnyx/client.py +363 -0
- telnyx_mcp_server/telnyx/services/__init__.py +0 -0
- telnyx_mcp_server/telnyx/services/assistants.py +155 -0
- telnyx_mcp_server/telnyx/services/call_control.py +217 -0
- telnyx_mcp_server/telnyx/services/cloud_storage.py +289 -0
- telnyx_mcp_server/telnyx/services/connections.py +92 -0
- telnyx_mcp_server/telnyx/services/embeddings.py +52 -0
- telnyx_mcp_server/telnyx/services/messaging.py +93 -0
- telnyx_mcp_server/telnyx/services/messaging_profiles.py +196 -0
- telnyx_mcp_server/telnyx/services/numbers.py +193 -0
- telnyx_mcp_server/telnyx/services/secrets.py +74 -0
- telnyx_mcp_server/tools/__init__.py +126 -0
- telnyx_mcp_server/tools/assistants.py +313 -0
- telnyx_mcp_server/tools/call_control.py +242 -0
- telnyx_mcp_server/tools/cloud_storage.py +183 -0
- telnyx_mcp_server/tools/connections.py +78 -0
- telnyx_mcp_server/tools/embeddings.py +80 -0
- telnyx_mcp_server/tools/messaging.py +57 -0
- telnyx_mcp_server/tools/messaging_profiles.py +123 -0
- telnyx_mcp_server/tools/phone_numbers.py +161 -0
- telnyx_mcp_server/tools/secrets.py +75 -0
- telnyx_mcp_server/tools/sms_conversations.py +455 -0
- telnyx_mcp_server/tools/webhooks.py +111 -0
- telnyx_mcp_server/utils/__init__.py +0 -0
- telnyx_mcp_server/utils/error_handler.py +30 -0
- telnyx_mcp_server/utils/logger.py +32 -0
- telnyx_mcp_server/utils/service.py +33 -0
- telnyx_mcp_server/webhook/__init__.py +25 -0
- telnyx_mcp_server/webhook/handler.py +596 -0
- telnyx_mcp_server/webhook/server.py +369 -0
- telnyx_mcp_server_fastmcp-0.1.3.dist-info/METADATA +430 -0
- telnyx_mcp_server_fastmcp-0.1.3.dist-info/RECORD +40 -0
- telnyx_mcp_server_fastmcp-0.1.3.dist-info/WHEEL +4 -0
- telnyx_mcp_server_fastmcp-0.1.3.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Logging utilities."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ..config import settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_logger(name: Optional[str] = None) -> logging.Logger:
|
|
11
|
+
"""Get a logger.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
name: Logger name
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
logging.Logger: Logger
|
|
18
|
+
"""
|
|
19
|
+
logger = logging.getLogger(name)
|
|
20
|
+
logger.setLevel(getattr(logging, settings.log_level))
|
|
21
|
+
|
|
22
|
+
# Add console handler if not already added
|
|
23
|
+
if not logger.handlers:
|
|
24
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
25
|
+
handler.setLevel(getattr(logging, settings.log_level))
|
|
26
|
+
formatter = logging.Formatter(
|
|
27
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
28
|
+
)
|
|
29
|
+
handler.setFormatter(formatter)
|
|
30
|
+
logger.addHandler(handler)
|
|
31
|
+
|
|
32
|
+
return logger
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Service utilities."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Type
|
|
4
|
+
|
|
5
|
+
from .logger import get_logger
|
|
6
|
+
|
|
7
|
+
logger = get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_authenticated_service(service_cls: Type[Any]) -> Any:
|
|
11
|
+
"""Get an authenticated service using the API key from environment.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
service_cls: Service class to instantiate
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Service instance with authenticated client
|
|
18
|
+
"""
|
|
19
|
+
logger.info(f"Getting authenticated service for {service_cls.__name__}")
|
|
20
|
+
|
|
21
|
+
# Use the client from mcp.py that's already initialized with API key
|
|
22
|
+
from ..mcp import telnyx_client
|
|
23
|
+
|
|
24
|
+
# Log masked API key at debug level
|
|
25
|
+
if hasattr(telnyx_client, "api_key") and telnyx_client.api_key:
|
|
26
|
+
masked_key = (
|
|
27
|
+
f"{telnyx_client.api_key[:5]}..."
|
|
28
|
+
if len(telnyx_client.api_key) > 5
|
|
29
|
+
else "[REDACTED]"
|
|
30
|
+
)
|
|
31
|
+
logger.debug(f"Using client with API key: {masked_key}")
|
|
32
|
+
|
|
33
|
+
return service_cls(telnyx_client)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Webhook receiver module for Telnyx MCP server."""
|
|
2
|
+
|
|
3
|
+
from .handler import NgrokTunnelHandler
|
|
4
|
+
from .server import get_webhook_history
|
|
5
|
+
|
|
6
|
+
# Create a single shared instance
|
|
7
|
+
webhook_handler = NgrokTunnelHandler()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def start_webhook_handler() -> str:
|
|
11
|
+
"""Start the webhook tunnel handler and return the public URL."""
|
|
12
|
+
return webhook_handler.start()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def stop_webhook_handler() -> None:
|
|
16
|
+
"""Stop the webhook tunnel handler."""
|
|
17
|
+
webhook_handler.stop()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"start_webhook_handler",
|
|
22
|
+
"stop_webhook_handler",
|
|
23
|
+
"get_webhook_history",
|
|
24
|
+
"webhook_handler",
|
|
25
|
+
]
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""Direct ngrok tunnel handler using Unix domain sockets."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import ngrok
|
|
11
|
+
except ImportError as e:
|
|
12
|
+
raise ImportError(
|
|
13
|
+
f"Failed to import ngrok. Please install it with 'pip install ngrok>=0.9.0'. Error: {e}"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from ..config import settings
|
|
17
|
+
from ..utils.logger import get_logger
|
|
18
|
+
from .server import (
|
|
19
|
+
start_webhook_server,
|
|
20
|
+
stop_webhook_server,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NgrokTunnelHandler:
|
|
27
|
+
"""
|
|
28
|
+
Ngrok tunnel handler using the official Python SDK without binary dependencies.
|
|
29
|
+
|
|
30
|
+
This class creates an ngrok tunnel directly using the ngrok-python SDK
|
|
31
|
+
connecting to a Unix domain socket, avoiding port conflicts.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
"""Initialize the tunnel handler."""
|
|
36
|
+
self.listener = None
|
|
37
|
+
self.public_url = None
|
|
38
|
+
self._reconnect_thread = None
|
|
39
|
+
self._running = False
|
|
40
|
+
self.socket_path = None
|
|
41
|
+
self._tunnel_was_established = (
|
|
42
|
+
False # Track if a tunnel was ever successfully established
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Clean up internal SDK resources only (not processes)
|
|
46
|
+
self._cleanup_sdk_resources()
|
|
47
|
+
|
|
48
|
+
def _cleanup_sdk_resources(self):
|
|
49
|
+
"""Thoroughly clean up ngrok SDK resources without affecting external processes."""
|
|
50
|
+
logger.info("Performing thorough ngrok SDK resources cleanup...")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# Use the Python API to clean up resources
|
|
54
|
+
import ngrok
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# Multiple disconnect attempts with different approaches
|
|
58
|
+
try:
|
|
59
|
+
# First generic disconnect
|
|
60
|
+
ngrok.disconnect()
|
|
61
|
+
time.sleep(0.5)
|
|
62
|
+
|
|
63
|
+
# Then try to list and disconnect any tunnels specifically
|
|
64
|
+
if hasattr(ngrok, "list_tunnels"):
|
|
65
|
+
tunnels = ngrok.list_tunnels()
|
|
66
|
+
for tunnel in tunnels:
|
|
67
|
+
try:
|
|
68
|
+
if hasattr(tunnel, "url"):
|
|
69
|
+
ngrok.disconnect(tunnel.url())
|
|
70
|
+
elif hasattr(tunnel, "public_url"):
|
|
71
|
+
ngrok.disconnect(tunnel.public_url)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
logger.info("Reset all ngrok connections via SDK")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.warning(f"Failed to reset connections: {e}")
|
|
78
|
+
|
|
79
|
+
# Comprehensive module state cleanup
|
|
80
|
+
try:
|
|
81
|
+
# Clear anything that might store state
|
|
82
|
+
attrs_to_clear = [
|
|
83
|
+
"_tunnels",
|
|
84
|
+
"_listeners",
|
|
85
|
+
"_sessions",
|
|
86
|
+
"_endpoints",
|
|
87
|
+
"_configs",
|
|
88
|
+
"_connections",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
for attr in attrs_to_clear:
|
|
92
|
+
if hasattr(ngrok, attr):
|
|
93
|
+
if isinstance(getattr(ngrok, attr), dict):
|
|
94
|
+
setattr(ngrok, attr, {})
|
|
95
|
+
elif isinstance(getattr(ngrok, attr), list):
|
|
96
|
+
setattr(ngrok, attr, [])
|
|
97
|
+
|
|
98
|
+
# Reset API client if possible
|
|
99
|
+
if hasattr(ngrok, "api") and hasattr(ngrok.api, "_client"):
|
|
100
|
+
ngrok.api._client = None
|
|
101
|
+
|
|
102
|
+
logger.info("Reset all ngrok internal module state")
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.warning(f"Failed during ngrok reset: {e}")
|
|
107
|
+
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.warning(f"Error during ngrok SDK cleanup: {e}")
|
|
110
|
+
|
|
111
|
+
# Immediately try to patch NGrok to ignore domain settings
|
|
112
|
+
try:
|
|
113
|
+
import sys
|
|
114
|
+
|
|
115
|
+
import ngrok
|
|
116
|
+
|
|
117
|
+
# Try to force dynamic domains by monkey patching
|
|
118
|
+
if hasattr(ngrok, "Config"):
|
|
119
|
+
original_init = ngrok.Config.__init__
|
|
120
|
+
|
|
121
|
+
# Define a patched init that ignores domain
|
|
122
|
+
def patched_init(self, *args, **kwargs):
|
|
123
|
+
# Force domain to None before calling original
|
|
124
|
+
if "domain" in kwargs:
|
|
125
|
+
kwargs["domain"] = None
|
|
126
|
+
return original_init(self, *args, **kwargs)
|
|
127
|
+
|
|
128
|
+
# Apply the patch
|
|
129
|
+
ngrok.Config.__init__ = patched_init
|
|
130
|
+
logger.info(
|
|
131
|
+
"Successfully patched NGrok Config to force dynamic domains"
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
logger.info(
|
|
135
|
+
"NGrok Config not found, could not apply domain patch"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Also try to delete any module-level domain setting
|
|
139
|
+
ngrok_modules = [m for m in sys.modules if m.startswith("ngrok")]
|
|
140
|
+
for module_name in ngrok_modules:
|
|
141
|
+
module = sys.modules[module_name]
|
|
142
|
+
if hasattr(module, "domain"):
|
|
143
|
+
setattr(module, "domain", None)
|
|
144
|
+
logger.info(f"Reset domain in module {module_name}")
|
|
145
|
+
except Exception as patch_err:
|
|
146
|
+
logger.warning(f"Error patching NGrok (non-fatal): {patch_err}")
|
|
147
|
+
|
|
148
|
+
def start(self) -> Optional[str]:
|
|
149
|
+
"""
|
|
150
|
+
Start the ngrok tunnel.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Optional[str]: The public URL for webhooks or None if startup fails
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
SystemExit: If webhook_enabled is true but the tunnel cannot be created
|
|
157
|
+
"""
|
|
158
|
+
if not settings.webhook_enabled:
|
|
159
|
+
logger.info("Webhook handler is disabled")
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
if not settings.ngrok_enabled:
|
|
163
|
+
# If webhooks are enabled but ngrok is disabled, this is a fatal configuration error
|
|
164
|
+
logger.critical(
|
|
165
|
+
"FATAL ERROR: Webhook handler is enabled but NGrok is disabled"
|
|
166
|
+
)
|
|
167
|
+
logger.critical(
|
|
168
|
+
"Webhooks cannot function without NGrok - exiting process"
|
|
169
|
+
)
|
|
170
|
+
sys.exit(1) # Exit with error status code
|
|
171
|
+
return None # This will never execute
|
|
172
|
+
|
|
173
|
+
# Add authtoken if available
|
|
174
|
+
if settings.ngrok_authtoken:
|
|
175
|
+
# Set for all connections
|
|
176
|
+
try:
|
|
177
|
+
ngrok.set_auth_token(settings.ngrok_authtoken)
|
|
178
|
+
logger.info("Using ngrok authentication token")
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error(f"Failed to set ngrok authentication token: {e}")
|
|
181
|
+
return None
|
|
182
|
+
else:
|
|
183
|
+
logger.warning(
|
|
184
|
+
"No ngrok authentication token provided - tunnel may fail"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# First start the Unix socket server
|
|
188
|
+
self.socket_path = start_webhook_server()
|
|
189
|
+
if not self.socket_path:
|
|
190
|
+
logger.error(
|
|
191
|
+
"Failed to start webhook server. Cannot create ngrok tunnel."
|
|
192
|
+
)
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
# CRITICAL SAFETY CHECK: Force use of dynamic NGrok URLs
|
|
196
|
+
# Ignore any custom domain settings to avoid TLS termination errors
|
|
197
|
+
# Directly set ngrok_url to None in settings object to override any value
|
|
198
|
+
if hasattr(settings, "ngrok_url") and settings.ngrok_url is not None:
|
|
199
|
+
logger.warning(f"Detected custom domain: {settings.ngrok_url}")
|
|
200
|
+
logger.warning("OVERRIDING to force dynamic NGrok URL")
|
|
201
|
+
# Hard override the setting at runtime
|
|
202
|
+
try:
|
|
203
|
+
# This is a hack but necessary to force dynamic domains
|
|
204
|
+
object.__setattr__(settings, "ngrok_url", None)
|
|
205
|
+
logger.info("Successfully forced ngrok_url to None")
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.warning(f"Could not override settings.ngrok_url: {e}")
|
|
208
|
+
|
|
209
|
+
# Even if the above fails, still log that we're forcing dynamic domains
|
|
210
|
+
logger.info("Forcing dynamic domain assignment for NGrok")
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
# Aggressive cleanup of any existing ngrok resources (our own only)
|
|
214
|
+
try:
|
|
215
|
+
logger.info(
|
|
216
|
+
"Aggressively cleaning up existing ngrok SDK resources..."
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Multiple disconnect attempts to be thorough
|
|
220
|
+
try:
|
|
221
|
+
ngrok.disconnect()
|
|
222
|
+
time.sleep(0.5)
|
|
223
|
+
# Try again to be sure
|
|
224
|
+
ngrok.disconnect()
|
|
225
|
+
time.sleep(0.5)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
# Clean up internal SDK state more thoroughly
|
|
230
|
+
try:
|
|
231
|
+
# Reset any module-level state
|
|
232
|
+
import importlib
|
|
233
|
+
|
|
234
|
+
ngrok_module = importlib.import_module("ngrok")
|
|
235
|
+
|
|
236
|
+
# Clear any cached tunnels
|
|
237
|
+
if hasattr(ngrok_module, "_tunnels"):
|
|
238
|
+
ngrok_module._tunnels = {}
|
|
239
|
+
if hasattr(ngrok_module, "_listeners"):
|
|
240
|
+
ngrok_module._listeners = {}
|
|
241
|
+
if hasattr(ngrok_module, "api"):
|
|
242
|
+
# Reset API client if possible
|
|
243
|
+
ngrok_module.api._client = None
|
|
244
|
+
|
|
245
|
+
logger.info("Reset ngrok module internal state completely")
|
|
246
|
+
except Exception as reset_err:
|
|
247
|
+
logger.warning(
|
|
248
|
+
f"Could not fully reset ngrok module state: {reset_err}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Wait a bit to let any previous sessions expire
|
|
252
|
+
time.sleep(1)
|
|
253
|
+
except Exception as cleanup_err:
|
|
254
|
+
logger.warning(
|
|
255
|
+
f"Failed during aggressive ngrok cleanup: {cleanup_err}"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Prepare the Unix socket address
|
|
259
|
+
unix_sock_addr = f"unix:{self.socket_path}"
|
|
260
|
+
logger.info(f"Forwarding to Unix socket: {unix_sock_addr}")
|
|
261
|
+
|
|
262
|
+
# More aggressive options to force dynamic domain - direct approach
|
|
263
|
+
options = {
|
|
264
|
+
"authtoken_from_env": bool(settings.ngrok_authtoken),
|
|
265
|
+
"metadata": "Telnyx MCP Webhook Handler (Direct)",
|
|
266
|
+
"verify_webhook_provider": "", # Disable webhook verification
|
|
267
|
+
"app_protocol": "http1", # Explicitly use HTTP/1.1
|
|
268
|
+
"domain": None, # Explicitly set domain to None to force dynamic assignment
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
# Force override any custom domain settings from environment or cache
|
|
272
|
+
if hasattr(ngrok, "set_config"):
|
|
273
|
+
try:
|
|
274
|
+
ngrok.set_config(domain=None)
|
|
275
|
+
logger.info(
|
|
276
|
+
"Explicitly disabled custom domain in NGrok configuration"
|
|
277
|
+
)
|
|
278
|
+
except Exception as config_err:
|
|
279
|
+
logger.warning(
|
|
280
|
+
f"Could not override NGrok domain config: {config_err}"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Create the tunnel - use the most direct approach possible
|
|
284
|
+
logger.info(
|
|
285
|
+
f"Creating ngrok tunnel with direct approach and options: {options}"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Instead of using the Session API which is causing TLS termination issues,
|
|
289
|
+
# Use a simplified direct approach without any custom domains or TLS options
|
|
290
|
+
try:
|
|
291
|
+
# Generate an extremely simplified set of options - remove TLS-related options
|
|
292
|
+
simplified_options = {
|
|
293
|
+
"authtoken_from_env": bool(settings.ngrok_authtoken),
|
|
294
|
+
"metadata": "Telnyx MCP Webhook Handler (Simplified)",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
# Skip the Session API approach entirely and use the basic forward function
|
|
298
|
+
logger.info(
|
|
299
|
+
f"Creating NGrok tunnel with simplified options: {simplified_options}"
|
|
300
|
+
)
|
|
301
|
+
self.listener = ngrok.forward(
|
|
302
|
+
unix_sock_addr, **simplified_options
|
|
303
|
+
)
|
|
304
|
+
logger.info(
|
|
305
|
+
"Created NGrok tunnel using simplified forward method"
|
|
306
|
+
)
|
|
307
|
+
except Exception as module_err:
|
|
308
|
+
# Final fallback with minimal options and different syntax
|
|
309
|
+
logger.warning(
|
|
310
|
+
f"First approach failed: {module_err}, trying minimal fallback"
|
|
311
|
+
)
|
|
312
|
+
# Try with the absolute minimum options possible
|
|
313
|
+
minimal_options = {
|
|
314
|
+
"authtoken_from_env": bool(settings.ngrok_authtoken),
|
|
315
|
+
}
|
|
316
|
+
logger.info(
|
|
317
|
+
f"Creating NGrok tunnel with minimal options: {minimal_options}"
|
|
318
|
+
)
|
|
319
|
+
self.listener = ngrok.forward(
|
|
320
|
+
unix_sock_addr, **minimal_options
|
|
321
|
+
)
|
|
322
|
+
logger.info(
|
|
323
|
+
"Created NGrok tunnel using minimal options approach"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Get the public URL
|
|
327
|
+
self.public_url = self.listener.url()
|
|
328
|
+
|
|
329
|
+
# Mark that a tunnel was successfully established
|
|
330
|
+
self._tunnel_was_established = True
|
|
331
|
+
|
|
332
|
+
# Start reconnection monitor
|
|
333
|
+
self._running = True
|
|
334
|
+
self._start_reconnect_thread()
|
|
335
|
+
|
|
336
|
+
logger.info(
|
|
337
|
+
f"Ngrok tunnel established with dynamic URL: {self.public_url}"
|
|
338
|
+
)
|
|
339
|
+
logger.info(
|
|
340
|
+
f"Webhook endpoint available at: {self.public_url}{settings.webhook_path}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Add a webhook to history to show it's working
|
|
344
|
+
self._add_to_history(
|
|
345
|
+
{
|
|
346
|
+
"event_type": "ngrok.tunnel.started",
|
|
347
|
+
"timestamp": datetime.now().isoformat(),
|
|
348
|
+
"url": self.public_url,
|
|
349
|
+
"webhook_endpoint": f"{self.public_url}{settings.webhook_path}",
|
|
350
|
+
"dynamic_url": True,
|
|
351
|
+
}
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
return self.public_url
|
|
355
|
+
except Exception as e:
|
|
356
|
+
error_message = str(e)
|
|
357
|
+
logger.error(f"Failed to start ngrok tunnel: {error_message}")
|
|
358
|
+
|
|
359
|
+
# If session limit error, provide guidance but don't kill processes
|
|
360
|
+
if "ERR_NGROK_108" in error_message:
|
|
361
|
+
logger.error(
|
|
362
|
+
"Detected NGrok session limit error - cannot create a new tunnel"
|
|
363
|
+
)
|
|
364
|
+
logger.error(
|
|
365
|
+
"There are other NGrok sessions running that need to be closed"
|
|
366
|
+
)
|
|
367
|
+
logger.error(
|
|
368
|
+
"Manual intervention required: check https://dashboard.ngrok.com/agents"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# Record the error in webhook history
|
|
372
|
+
self._add_to_history(
|
|
373
|
+
{
|
|
374
|
+
"event_type": "ngrok.error",
|
|
375
|
+
"timestamp": datetime.now().isoformat(),
|
|
376
|
+
"error": error_message,
|
|
377
|
+
"error_type": "tls_error"
|
|
378
|
+
if "tls" in error_message.lower()
|
|
379
|
+
or "missing key" in error_message.lower()
|
|
380
|
+
else "session_limit"
|
|
381
|
+
if "ERR_NGROK_108" in error_message
|
|
382
|
+
else "unknown",
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# For any error, just log it and exit with a clear error message
|
|
387
|
+
# Check for the specific ERR_NGROK_108 error (session limit)
|
|
388
|
+
if "ERR_NGROK_108" in error_message:
|
|
389
|
+
logger.critical(
|
|
390
|
+
f"FATAL ERROR: NGrok session limit exceeded: {error_message}"
|
|
391
|
+
)
|
|
392
|
+
logger.critical(
|
|
393
|
+
"You currently have other NGrok sessions running. To fix this issue:"
|
|
394
|
+
)
|
|
395
|
+
logger.critical(
|
|
396
|
+
"1. Go to https://dashboard.ngrok.com/agents to manage existing sessions"
|
|
397
|
+
)
|
|
398
|
+
logger.critical(
|
|
399
|
+
"2. Or wait for other sessions to complete before restarting"
|
|
400
|
+
)
|
|
401
|
+
logger.critical(
|
|
402
|
+
"3. Consider upgrading NGrok plan for more simultaneous sessions"
|
|
403
|
+
)
|
|
404
|
+
else:
|
|
405
|
+
logger.critical(
|
|
406
|
+
f"FATAL ERROR: NGrok tunnel failed to start: {error_message}"
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Clean up and exit with a clear error message - webhooks are critical
|
|
410
|
+
stop_webhook_server()
|
|
411
|
+
logger.critical(
|
|
412
|
+
"FATAL ERROR: Cannot continue without NGrok tunnel for webhooks"
|
|
413
|
+
)
|
|
414
|
+
logger.critical(f"Specific error: {error_message}")
|
|
415
|
+
logger.critical(
|
|
416
|
+
"Please fix NGrok configuration issues before restarting"
|
|
417
|
+
)
|
|
418
|
+
sys.exit(1) # Exit with error code - fail decisively
|
|
419
|
+
|
|
420
|
+
def _start_reconnect_thread(self):
|
|
421
|
+
"""Start a thread to monitor and reconnect the tunnel if needed."""
|
|
422
|
+
if self._reconnect_thread and self._reconnect_thread.is_alive():
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
self._reconnect_thread = threading.Thread(
|
|
426
|
+
target=self._reconnect_loop,
|
|
427
|
+
daemon=True,
|
|
428
|
+
)
|
|
429
|
+
self._reconnect_thread.start()
|
|
430
|
+
|
|
431
|
+
def _reconnect_loop(self):
|
|
432
|
+
"""Background thread: monitor the tunnel and reconnect if needed."""
|
|
433
|
+
logger.info("Starting ngrok tunnel monitor thread")
|
|
434
|
+
reconnection_attempts = 0
|
|
435
|
+
max_reconnection_attempts = 3
|
|
436
|
+
|
|
437
|
+
while self._running:
|
|
438
|
+
time.sleep(30) # Check every 30 seconds
|
|
439
|
+
|
|
440
|
+
if not self._running:
|
|
441
|
+
break
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
# If we have a listener and public URL, we're good
|
|
445
|
+
if self.listener and self.public_url:
|
|
446
|
+
reconnection_attempts = (
|
|
447
|
+
0 # Reset counter on successful connection
|
|
448
|
+
)
|
|
449
|
+
continue
|
|
450
|
+
else:
|
|
451
|
+
# If the tunnel was previously established but is now gone, this is a critical error
|
|
452
|
+
if (
|
|
453
|
+
self._running
|
|
454
|
+
and hasattr(self, "_tunnel_was_established")
|
|
455
|
+
and self._tunnel_was_established
|
|
456
|
+
):
|
|
457
|
+
logger.critical(
|
|
458
|
+
"FATAL ERROR: NGrok tunnel was previously established but has been lost"
|
|
459
|
+
)
|
|
460
|
+
logger.critical(
|
|
461
|
+
"Webhooks are required for MCP server operation"
|
|
462
|
+
)
|
|
463
|
+
logger.critical(
|
|
464
|
+
"The server will exit. Please restart when NGrok is available."
|
|
465
|
+
)
|
|
466
|
+
sys.exit(1) # Exit with error code - fail decisively
|
|
467
|
+
|
|
468
|
+
# Check if we've exceeded max reconnection attempts
|
|
469
|
+
if reconnection_attempts >= max_reconnection_attempts:
|
|
470
|
+
logger.critical(
|
|
471
|
+
f"FATAL ERROR: Exceeded {max_reconnection_attempts} NGrok tunnel reconnection attempts"
|
|
472
|
+
)
|
|
473
|
+
logger.critical(
|
|
474
|
+
"Webhooks are required for operation - exiting process immediately"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Record the terminal error in webhook history
|
|
478
|
+
try:
|
|
479
|
+
self._add_to_history(
|
|
480
|
+
{
|
|
481
|
+
"event_type": "ngrok.error.terminal",
|
|
482
|
+
"timestamp": datetime.now().isoformat(),
|
|
483
|
+
"error": f"Exceeded {max_reconnection_attempts} reconnection attempts",
|
|
484
|
+
"error_type": "reconnection_failure",
|
|
485
|
+
}
|
|
486
|
+
)
|
|
487
|
+
except Exception:
|
|
488
|
+
pass # Ignore any errors in recording history at this point
|
|
489
|
+
|
|
490
|
+
# Exit with a clear error message - webhooks are critical
|
|
491
|
+
logger.critical(
|
|
492
|
+
"FATAL ERROR: Max reconnection attempts exceeded for NGrok tunnel"
|
|
493
|
+
)
|
|
494
|
+
logger.critical(
|
|
495
|
+
"Webhooks are required for MCP server operation"
|
|
496
|
+
)
|
|
497
|
+
sys.exit(1) # Exit with error code - fail decisively
|
|
498
|
+
|
|
499
|
+
# Increment attempt counter
|
|
500
|
+
reconnection_attempts += 1
|
|
501
|
+
logger.warning(
|
|
502
|
+
f"Tunnel not active, attempting to reconnect (attempt {reconnection_attempts}/{max_reconnection_attempts})..."
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Try to stop existing resources
|
|
506
|
+
try:
|
|
507
|
+
self.stop()
|
|
508
|
+
except Exception as stop_error:
|
|
509
|
+
logger.error(
|
|
510
|
+
f"Error stopping existing tunnel: {stop_error}"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
time.sleep(2) # Brief delay before reconnecting
|
|
514
|
+
|
|
515
|
+
# Try to start a new tunnel
|
|
516
|
+
try:
|
|
517
|
+
new_url = self.start()
|
|
518
|
+
if new_url:
|
|
519
|
+
logger.info(
|
|
520
|
+
f"Successfully reconnected tunnel. New URL: {new_url}"
|
|
521
|
+
)
|
|
522
|
+
reconnection_attempts = 0 # Reset counter on success
|
|
523
|
+
else:
|
|
524
|
+
logger.error("Failed to reconnect tunnel")
|
|
525
|
+
except Exception as start_error:
|
|
526
|
+
logger.error(f"Error starting new tunnel: {start_error}")
|
|
527
|
+
except Exception as e:
|
|
528
|
+
logger.error(f"Error in tunnel monitor thread: {e}")
|
|
529
|
+
|
|
530
|
+
logger.info("Tunnel monitor thread stopped")
|
|
531
|
+
|
|
532
|
+
def _add_to_history(self, data):
|
|
533
|
+
"""Add an event to the webhook history."""
|
|
534
|
+
from .server import webhook_history
|
|
535
|
+
|
|
536
|
+
webhook_history.appendleft(
|
|
537
|
+
{
|
|
538
|
+
"timestamp": datetime.now().isoformat(),
|
|
539
|
+
"event_type": data.get("event_type", "unknown"),
|
|
540
|
+
"payload": data,
|
|
541
|
+
}
|
|
542
|
+
)
|
|
543
|
+
logger.debug(f"Added event to history (total: {len(webhook_history)})")
|
|
544
|
+
|
|
545
|
+
def stop(self) -> None:
|
|
546
|
+
"""Stop the tunnel and clean up resources."""
|
|
547
|
+
self._running = False
|
|
548
|
+
|
|
549
|
+
# First stop the reconnect thread to prevent race conditions
|
|
550
|
+
if self._reconnect_thread and self._reconnect_thread.is_alive():
|
|
551
|
+
logger.debug("Stopping reconnect thread...")
|
|
552
|
+
try:
|
|
553
|
+
self._reconnect_thread.join(timeout=5.0)
|
|
554
|
+
if self._reconnect_thread.is_alive():
|
|
555
|
+
logger.warning(
|
|
556
|
+
"Reconnect thread did not terminate in time"
|
|
557
|
+
)
|
|
558
|
+
except Exception as thread_err:
|
|
559
|
+
logger.error(f"Error stopping reconnect thread: {thread_err}")
|
|
560
|
+
|
|
561
|
+
# Then stop the tunnel
|
|
562
|
+
if self.listener:
|
|
563
|
+
try:
|
|
564
|
+
logger.info("Stopping ngrok tunnel...")
|
|
565
|
+
|
|
566
|
+
# Try all available methods to ensure the tunnel is stopped
|
|
567
|
+
try:
|
|
568
|
+
# Handle different ngrok SDK versions
|
|
569
|
+
if hasattr(self.listener, "close"):
|
|
570
|
+
# Newer SDK versions
|
|
571
|
+
self.listener.close()
|
|
572
|
+
except Exception as close_err:
|
|
573
|
+
logger.warning(f"Error closing listener: {close_err}")
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
# Try disconnecting specifically for older SDK versions
|
|
577
|
+
if hasattr(ngrok, "disconnect"):
|
|
578
|
+
if self.public_url:
|
|
579
|
+
ngrok.disconnect(self.public_url)
|
|
580
|
+
else:
|
|
581
|
+
# Fallback - disconnect all
|
|
582
|
+
ngrok.disconnect()
|
|
583
|
+
except Exception as disconnect_err:
|
|
584
|
+
logger.warning(f"Error disconnecting: {disconnect_err}")
|
|
585
|
+
|
|
586
|
+
logger.info("Ngrok tunnel stopped successfully")
|
|
587
|
+
except Exception as e:
|
|
588
|
+
logger.error(f"Error stopping ngrok tunnel: {e}")
|
|
589
|
+
|
|
590
|
+
# Stop the webhook server
|
|
591
|
+
stop_webhook_server()
|
|
592
|
+
|
|
593
|
+
# Always reset these variables regardless of any errors
|
|
594
|
+
self.listener = None
|
|
595
|
+
self.public_url = None
|
|
596
|
+
self.socket_path = None
|