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,369 @@
|
|
|
1
|
+
"""Lightweight Unix Socket server for handling webhooks without port conflicts."""
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from http.server import BaseHTTPRequestHandler
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from socketserver import UnixStreamServer
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from ..config import settings
|
|
14
|
+
from ..utils.logger import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
# Store webhook history (most recent first)
|
|
19
|
+
webhook_history = deque(maxlen=100) # Store last 100 webhooks
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnixSocketHandler(BaseHTTPRequestHandler):
|
|
23
|
+
"""HTTP request handler for Unix domain sockets."""
|
|
24
|
+
|
|
25
|
+
# Ensure HTTP/1.1 is used consistently
|
|
26
|
+
protocol_version = "HTTP/1.1"
|
|
27
|
+
|
|
28
|
+
# Override address_string to fix the IndexError with Unix sockets
|
|
29
|
+
def address_string(self):
|
|
30
|
+
"""Return a string representation of the client address."""
|
|
31
|
+
# For Unix sockets, client_address is often a string or None
|
|
32
|
+
# Just return a placeholder instead of trying to index it
|
|
33
|
+
return "unix-client"
|
|
34
|
+
|
|
35
|
+
# Override log_message to ensure it doesn't fail with Unix sockets
|
|
36
|
+
def log_message(self, format, *args):
|
|
37
|
+
"""Log an arbitrary message to the server log."""
|
|
38
|
+
# Use a more robust implementation that won't fail with Unix sockets
|
|
39
|
+
try:
|
|
40
|
+
message = format % args
|
|
41
|
+
logger.info(
|
|
42
|
+
f"[UnixSocketHandler] {self.command} {self.path} - {message}"
|
|
43
|
+
)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
# Failsafe logging that doesn't rely on string formatting
|
|
46
|
+
logger.info(
|
|
47
|
+
f"[UnixSocketHandler] Request processed: {self.command} {self.path}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def do_POST(self):
|
|
51
|
+
"""Handle POST requests from the ngrok tunnel."""
|
|
52
|
+
try:
|
|
53
|
+
# Get content length
|
|
54
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
55
|
+
if content_length > settings.webhook_max_body_size:
|
|
56
|
+
self.send_error(413, "Request entity too large")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Read the request body
|
|
60
|
+
body = self.rfile.read(content_length)
|
|
61
|
+
|
|
62
|
+
# Process the webhook request
|
|
63
|
+
self._process_webhook(body)
|
|
64
|
+
|
|
65
|
+
# Prepare a valid JSON response
|
|
66
|
+
response = json.dumps(
|
|
67
|
+
{
|
|
68
|
+
"status": "success",
|
|
69
|
+
"message": "Webhook received",
|
|
70
|
+
"timestamp": datetime.now().isoformat(),
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
response_bytes = response.encode("utf-8")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
# Send a properly formatted HTTP response with more robust error handling
|
|
77
|
+
self.send_response(200)
|
|
78
|
+
# Set headers with proper HTTP/1.1 compliance
|
|
79
|
+
self.send_header(
|
|
80
|
+
"Content-Type", "application/json; charset=utf-8"
|
|
81
|
+
)
|
|
82
|
+
self.send_header("Content-Length", str(len(response_bytes)))
|
|
83
|
+
self.send_header(
|
|
84
|
+
"Connection", "close"
|
|
85
|
+
) # Important for HTTP/1.1
|
|
86
|
+
# Add more headers to help with debugging
|
|
87
|
+
self.send_header("X-Webhook-Handler", "Telnyx-MCP-Unix-Socket")
|
|
88
|
+
self.send_header("Cache-Control", "no-store, no-cache")
|
|
89
|
+
self.end_headers()
|
|
90
|
+
|
|
91
|
+
# Write the response in a try-except block
|
|
92
|
+
try:
|
|
93
|
+
self.wfile.write(response_bytes)
|
|
94
|
+
self.wfile.flush() # Ensure the response is sent completely
|
|
95
|
+
except (BrokenPipeError, ConnectionResetError) as pipe_error:
|
|
96
|
+
# Client disconnected - log but don't re-raise
|
|
97
|
+
logger.warning(
|
|
98
|
+
f"Client disconnected during response: {pipe_error}"
|
|
99
|
+
)
|
|
100
|
+
except Exception as write_error:
|
|
101
|
+
logger.error(f"Error writing response: {write_error}")
|
|
102
|
+
# Don't re-raise - we've already processed the webhook
|
|
103
|
+
except Exception as resp_error:
|
|
104
|
+
logger.error(f"Error sending HTTP response: {resp_error}")
|
|
105
|
+
# Don't re-raise - we've already processed the webhook
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"Error handling webhook: {str(e)}")
|
|
108
|
+
# Send a properly formatted error response with enhanced robustness
|
|
109
|
+
try:
|
|
110
|
+
error_response = json.dumps(
|
|
111
|
+
{
|
|
112
|
+
"status": "error",
|
|
113
|
+
"message": str(e),
|
|
114
|
+
"timestamp": datetime.now().isoformat(),
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
error_bytes = error_response.encode("utf-8")
|
|
118
|
+
|
|
119
|
+
self.send_response(500)
|
|
120
|
+
self.send_header(
|
|
121
|
+
"Content-Type", "application/json; charset=utf-8"
|
|
122
|
+
)
|
|
123
|
+
self.send_header("Content-Length", str(len(error_bytes)))
|
|
124
|
+
self.send_header("Connection", "close")
|
|
125
|
+
self.send_header("X-Webhook-Handler", "Telnyx-MCP-Unix-Socket")
|
|
126
|
+
self.end_headers()
|
|
127
|
+
|
|
128
|
+
# Write error response with exception handling
|
|
129
|
+
try:
|
|
130
|
+
self.wfile.write(error_bytes)
|
|
131
|
+
self.wfile.flush()
|
|
132
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
133
|
+
# Client disconnected - just log it
|
|
134
|
+
logger.warning("Client disconnected during error response")
|
|
135
|
+
except Exception as write_err:
|
|
136
|
+
logger.error(f"Error writing error response: {write_err}")
|
|
137
|
+
except Exception as resp_err:
|
|
138
|
+
logger.error(f"Failed to send error response: {resp_err}")
|
|
139
|
+
|
|
140
|
+
def do_GET(self):
|
|
141
|
+
"""Handle GET requests for health checks."""
|
|
142
|
+
try:
|
|
143
|
+
# Prepare a valid JSON response
|
|
144
|
+
response = json.dumps(
|
|
145
|
+
{
|
|
146
|
+
"status": "ok",
|
|
147
|
+
"time": datetime.now().isoformat(),
|
|
148
|
+
"path": self.path,
|
|
149
|
+
"webhook_server": "Telnyx-MCP-Unix-Socket",
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
response_bytes = response.encode("utf-8")
|
|
153
|
+
|
|
154
|
+
# Send a properly formatted HTTP response with robust error handling
|
|
155
|
+
self.send_response(200)
|
|
156
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
157
|
+
self.send_header("Content-Length", str(len(response_bytes)))
|
|
158
|
+
self.send_header("Connection", "close")
|
|
159
|
+
self.send_header("X-Webhook-Handler", "Telnyx-MCP-Unix-Socket")
|
|
160
|
+
self.send_header("Cache-Control", "no-store, no-cache")
|
|
161
|
+
self.end_headers()
|
|
162
|
+
|
|
163
|
+
# Write the response with exception handling
|
|
164
|
+
try:
|
|
165
|
+
self.wfile.write(response_bytes)
|
|
166
|
+
self.wfile.flush() # Ensure the response is sent completely
|
|
167
|
+
except (BrokenPipeError, ConnectionResetError) as pipe_error:
|
|
168
|
+
# Client disconnected - log but don't re-raise
|
|
169
|
+
logger.warning(
|
|
170
|
+
f"Client disconnected during health check response: {pipe_error}"
|
|
171
|
+
)
|
|
172
|
+
except Exception as write_error:
|
|
173
|
+
logger.error(
|
|
174
|
+
f"Error writing health check response: {write_error}"
|
|
175
|
+
)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.error(f"Error handling health check request: {str(e)}")
|
|
178
|
+
# Try to send an error response
|
|
179
|
+
try:
|
|
180
|
+
error_response = json.dumps(
|
|
181
|
+
{"status": "error", "message": str(e)}
|
|
182
|
+
)
|
|
183
|
+
error_bytes = error_response.encode("utf-8")
|
|
184
|
+
self.send_response(500)
|
|
185
|
+
self.send_header(
|
|
186
|
+
"Content-Type", "application/json; charset=utf-8"
|
|
187
|
+
)
|
|
188
|
+
self.send_header("Content-Length", str(len(error_bytes)))
|
|
189
|
+
self.send_header("Connection", "close")
|
|
190
|
+
self.end_headers()
|
|
191
|
+
self.wfile.write(error_bytes)
|
|
192
|
+
self.wfile.flush()
|
|
193
|
+
except Exception:
|
|
194
|
+
# If that also fails, just log and continue
|
|
195
|
+
logger.error("Failed to send health check error response")
|
|
196
|
+
|
|
197
|
+
def _process_webhook(self, body: bytes) -> None:
|
|
198
|
+
"""Process the webhook request."""
|
|
199
|
+
try:
|
|
200
|
+
# Parse the request body as JSON
|
|
201
|
+
if body:
|
|
202
|
+
payload = json.loads(body)
|
|
203
|
+
|
|
204
|
+
# Extract event type from payload - check both root level and nested data
|
|
205
|
+
# This handles both {event_type: "x"} and {data: {event_type: "x"}} formats
|
|
206
|
+
if "event_type" in payload:
|
|
207
|
+
event_type = payload.get("event_type", "unknown")
|
|
208
|
+
else:
|
|
209
|
+
# Fall back to checking in data object or default to unknown
|
|
210
|
+
event_type = payload.get("data", {}).get(
|
|
211
|
+
"event_type", "unknown"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Log the webhook
|
|
215
|
+
logger.info(f"Received webhook event: {event_type}")
|
|
216
|
+
logger.debug(
|
|
217
|
+
f"Webhook payload: {json.dumps(payload, indent=2)}"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Store in history
|
|
221
|
+
webhook_history.appendleft(
|
|
222
|
+
{
|
|
223
|
+
"timestamp": datetime.now().isoformat(),
|
|
224
|
+
"event_type": event_type,
|
|
225
|
+
"payload": payload,
|
|
226
|
+
"headers": dict(self.headers),
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
logger.debug(
|
|
231
|
+
f"Added webhook to history (total: {len(webhook_history)})"
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
logger.warning("Received empty webhook body")
|
|
235
|
+
except json.JSONDecodeError:
|
|
236
|
+
logger.error("Failed to parse webhook payload as JSON")
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.error(f"Error processing webhook: {str(e)}")
|
|
239
|
+
raise # Re-raise to allow proper error response
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class UnixSocketHTTPServer(UnixStreamServer):
|
|
243
|
+
"""HTTP server using Unix domain sockets."""
|
|
244
|
+
|
|
245
|
+
def __init__(self, server_address, RequestHandlerClass):
|
|
246
|
+
"""Initialize the server with a Unix socket."""
|
|
247
|
+
super().__init__(server_address, RequestHandlerClass)
|
|
248
|
+
self._thread = None
|
|
249
|
+
self._running = False
|
|
250
|
+
|
|
251
|
+
def start(self):
|
|
252
|
+
"""Start the server in a background thread."""
|
|
253
|
+
self._running = True
|
|
254
|
+
self._thread = threading.Thread(target=self.serve_forever)
|
|
255
|
+
self._thread.daemon = True
|
|
256
|
+
self._thread.start()
|
|
257
|
+
logger.info(f"Unix socket server started on {self.server_address}")
|
|
258
|
+
|
|
259
|
+
def stop(self):
|
|
260
|
+
"""Stop the server."""
|
|
261
|
+
if self._running:
|
|
262
|
+
self._running = False
|
|
263
|
+
self.shutdown()
|
|
264
|
+
if self._thread and self._thread.is_alive():
|
|
265
|
+
self._thread.join(timeout=5.0)
|
|
266
|
+
self.server_close()
|
|
267
|
+
|
|
268
|
+
# Clean up the socket file
|
|
269
|
+
try:
|
|
270
|
+
if os.path.exists(self.server_address):
|
|
271
|
+
os.unlink(self.server_address)
|
|
272
|
+
logger.info(f"Removed socket file: {self.server_address}")
|
|
273
|
+
except OSError as e:
|
|
274
|
+
logger.warning(f"Error removing socket file: {e}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# Global server instance
|
|
278
|
+
socket_server = None
|
|
279
|
+
socket_path = None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def generate_socket_path() -> str:
|
|
283
|
+
"""Generate a unique socket path."""
|
|
284
|
+
temp_dir = tempfile.mkdtemp(prefix="telnyx_mcp_")
|
|
285
|
+
socket_name = f"webhook-{os.getpid()}.sock"
|
|
286
|
+
path = os.path.join(temp_dir, socket_name)
|
|
287
|
+
return path
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def get_webhook_history(limit: int = None) -> List[Dict[str, Any]]:
|
|
291
|
+
"""
|
|
292
|
+
Get the webhook history.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
limit: Maximum number of webhooks to return (None for all)
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
List of webhook events, most recent first
|
|
299
|
+
"""
|
|
300
|
+
if limit is None or limit > len(webhook_history):
|
|
301
|
+
return list(webhook_history)
|
|
302
|
+
return list(webhook_history)[:limit]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def start_webhook_server() -> Optional[str]:
|
|
306
|
+
"""
|
|
307
|
+
Start the webhook server using a Unix domain socket.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Optional[str]: The socket path if successful, None otherwise
|
|
311
|
+
"""
|
|
312
|
+
global socket_server, socket_path
|
|
313
|
+
|
|
314
|
+
if not settings.webhook_enabled:
|
|
315
|
+
logger.info("Webhook server is disabled")
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
# Generate a unique socket path
|
|
320
|
+
socket_path = generate_socket_path()
|
|
321
|
+
logger.info(f"Using Unix domain socket at: {socket_path}")
|
|
322
|
+
|
|
323
|
+
# Create the directory if it doesn't exist
|
|
324
|
+
os.makedirs(os.path.dirname(socket_path), exist_ok=True)
|
|
325
|
+
|
|
326
|
+
# Clean up any existing socket file
|
|
327
|
+
if os.path.exists(socket_path):
|
|
328
|
+
try:
|
|
329
|
+
os.unlink(socket_path)
|
|
330
|
+
except OSError as e:
|
|
331
|
+
logger.warning(f"Could not remove existing socket file: {e}")
|
|
332
|
+
|
|
333
|
+
# Create and start the server
|
|
334
|
+
socket_server = UnixSocketHTTPServer(socket_path, UnixSocketHandler)
|
|
335
|
+
socket_server.start()
|
|
336
|
+
|
|
337
|
+
# Register cleanup on exit - but avoid signal handlers which won't work in all threads
|
|
338
|
+
import atexit
|
|
339
|
+
|
|
340
|
+
atexit.register(stop_webhook_server)
|
|
341
|
+
|
|
342
|
+
return socket_path
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.error(f"Failed to start webhook server: {e}")
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def stop_webhook_server() -> None:
|
|
349
|
+
"""Stop the webhook server."""
|
|
350
|
+
global socket_server, socket_path
|
|
351
|
+
|
|
352
|
+
if socket_server:
|
|
353
|
+
logger.info("Stopping webhook server...")
|
|
354
|
+
socket_server.stop()
|
|
355
|
+
socket_server = None
|
|
356
|
+
|
|
357
|
+
# Clean up the socket directory
|
|
358
|
+
if socket_path:
|
|
359
|
+
try:
|
|
360
|
+
dir_path = os.path.dirname(socket_path)
|
|
361
|
+
if os.path.exists(dir_path) and dir_path.startswith(
|
|
362
|
+
tempfile.gettempdir()
|
|
363
|
+
):
|
|
364
|
+
os.rmdir(dir_path)
|
|
365
|
+
logger.info(f"Removed socket directory: {dir_path}")
|
|
366
|
+
except OSError as e:
|
|
367
|
+
logger.warning(f"Error removing socket directory: {e}")
|
|
368
|
+
|
|
369
|
+
socket_path = None
|