signalwire-agents 0.1.13__py3-none-any.whl → 1.0.17.dev4__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.
Files changed (143) hide show
  1. signalwire_agents/__init__.py +99 -15
  2. signalwire_agents/agent_server.py +248 -60
  3. signalwire_agents/agents/bedrock.py +296 -0
  4. signalwire_agents/cli/__init__.py +9 -0
  5. signalwire_agents/cli/build_search.py +951 -41
  6. signalwire_agents/cli/config.py +80 -0
  7. signalwire_agents/cli/core/__init__.py +10 -0
  8. signalwire_agents/cli/core/agent_loader.py +470 -0
  9. signalwire_agents/cli/core/argparse_helpers.py +179 -0
  10. signalwire_agents/cli/core/dynamic_config.py +71 -0
  11. signalwire_agents/cli/core/service_loader.py +303 -0
  12. signalwire_agents/cli/dokku.py +2320 -0
  13. signalwire_agents/cli/execution/__init__.py +10 -0
  14. signalwire_agents/cli/execution/datamap_exec.py +446 -0
  15. signalwire_agents/cli/execution/webhook_exec.py +134 -0
  16. signalwire_agents/cli/init_project.py +2636 -0
  17. signalwire_agents/cli/output/__init__.py +10 -0
  18. signalwire_agents/cli/output/output_formatter.py +255 -0
  19. signalwire_agents/cli/output/swml_dump.py +186 -0
  20. signalwire_agents/cli/simulation/__init__.py +10 -0
  21. signalwire_agents/cli/simulation/data_generation.py +374 -0
  22. signalwire_agents/cli/simulation/data_overrides.py +200 -0
  23. signalwire_agents/cli/simulation/mock_env.py +282 -0
  24. signalwire_agents/cli/swaig_test_wrapper.py +52 -0
  25. signalwire_agents/cli/test_swaig.py +566 -2366
  26. signalwire_agents/cli/types.py +81 -0
  27. signalwire_agents/core/__init__.py +2 -2
  28. signalwire_agents/core/agent/__init__.py +12 -0
  29. signalwire_agents/core/agent/config/__init__.py +12 -0
  30. signalwire_agents/core/agent/deployment/__init__.py +9 -0
  31. signalwire_agents/core/agent/deployment/handlers/__init__.py +9 -0
  32. signalwire_agents/core/agent/prompt/__init__.py +14 -0
  33. signalwire_agents/core/agent/prompt/manager.py +306 -0
  34. signalwire_agents/core/agent/routing/__init__.py +9 -0
  35. signalwire_agents/core/agent/security/__init__.py +9 -0
  36. signalwire_agents/core/agent/swml/__init__.py +9 -0
  37. signalwire_agents/core/agent/tools/__init__.py +15 -0
  38. signalwire_agents/core/agent/tools/decorator.py +97 -0
  39. signalwire_agents/core/agent/tools/registry.py +210 -0
  40. signalwire_agents/core/agent_base.py +845 -2916
  41. signalwire_agents/core/auth_handler.py +233 -0
  42. signalwire_agents/core/config_loader.py +259 -0
  43. signalwire_agents/core/contexts.py +418 -0
  44. signalwire_agents/core/data_map.py +3 -15
  45. signalwire_agents/core/function_result.py +116 -44
  46. signalwire_agents/core/logging_config.py +162 -18
  47. signalwire_agents/core/mixins/__init__.py +28 -0
  48. signalwire_agents/core/mixins/ai_config_mixin.py +442 -0
  49. signalwire_agents/core/mixins/auth_mixin.py +280 -0
  50. signalwire_agents/core/mixins/prompt_mixin.py +358 -0
  51. signalwire_agents/core/mixins/serverless_mixin.py +460 -0
  52. signalwire_agents/core/mixins/skill_mixin.py +55 -0
  53. signalwire_agents/core/mixins/state_mixin.py +153 -0
  54. signalwire_agents/core/mixins/tool_mixin.py +230 -0
  55. signalwire_agents/core/mixins/web_mixin.py +1142 -0
  56. signalwire_agents/core/security_config.py +333 -0
  57. signalwire_agents/core/skill_base.py +84 -1
  58. signalwire_agents/core/skill_manager.py +62 -20
  59. signalwire_agents/core/swaig_function.py +18 -5
  60. signalwire_agents/core/swml_builder.py +207 -11
  61. signalwire_agents/core/swml_handler.py +27 -21
  62. signalwire_agents/core/swml_renderer.py +123 -312
  63. signalwire_agents/core/swml_service.py +171 -203
  64. signalwire_agents/mcp_gateway/__init__.py +29 -0
  65. signalwire_agents/mcp_gateway/gateway_service.py +564 -0
  66. signalwire_agents/mcp_gateway/mcp_manager.py +513 -0
  67. signalwire_agents/mcp_gateway/session_manager.py +218 -0
  68. signalwire_agents/prefabs/concierge.py +0 -3
  69. signalwire_agents/prefabs/faq_bot.py +0 -3
  70. signalwire_agents/prefabs/info_gatherer.py +0 -3
  71. signalwire_agents/prefabs/receptionist.py +0 -3
  72. signalwire_agents/prefabs/survey.py +0 -3
  73. signalwire_agents/schema.json +9218 -5489
  74. signalwire_agents/search/__init__.py +7 -1
  75. signalwire_agents/search/document_processor.py +490 -31
  76. signalwire_agents/search/index_builder.py +307 -37
  77. signalwire_agents/search/migration.py +418 -0
  78. signalwire_agents/search/models.py +30 -0
  79. signalwire_agents/search/pgvector_backend.py +748 -0
  80. signalwire_agents/search/query_processor.py +162 -31
  81. signalwire_agents/search/search_engine.py +916 -35
  82. signalwire_agents/search/search_service.py +376 -53
  83. signalwire_agents/skills/README.md +452 -0
  84. signalwire_agents/skills/__init__.py +14 -2
  85. signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
  86. signalwire_agents/skills/api_ninjas_trivia/__init__.py +12 -0
  87. signalwire_agents/skills/api_ninjas_trivia/skill.py +237 -0
  88. signalwire_agents/skills/datasphere/README.md +210 -0
  89. signalwire_agents/skills/datasphere/skill.py +84 -3
  90. signalwire_agents/skills/datasphere_serverless/README.md +258 -0
  91. signalwire_agents/skills/datasphere_serverless/__init__.py +9 -0
  92. signalwire_agents/skills/datasphere_serverless/skill.py +82 -1
  93. signalwire_agents/skills/datetime/README.md +132 -0
  94. signalwire_agents/skills/datetime/__init__.py +9 -0
  95. signalwire_agents/skills/datetime/skill.py +20 -7
  96. signalwire_agents/skills/joke/README.md +149 -0
  97. signalwire_agents/skills/joke/__init__.py +9 -0
  98. signalwire_agents/skills/joke/skill.py +21 -0
  99. signalwire_agents/skills/math/README.md +161 -0
  100. signalwire_agents/skills/math/__init__.py +9 -0
  101. signalwire_agents/skills/math/skill.py +18 -4
  102. signalwire_agents/skills/mcp_gateway/README.md +230 -0
  103. signalwire_agents/skills/mcp_gateway/__init__.py +10 -0
  104. signalwire_agents/skills/mcp_gateway/skill.py +421 -0
  105. signalwire_agents/skills/native_vector_search/README.md +210 -0
  106. signalwire_agents/skills/native_vector_search/__init__.py +9 -0
  107. signalwire_agents/skills/native_vector_search/skill.py +569 -101
  108. signalwire_agents/skills/play_background_file/README.md +218 -0
  109. signalwire_agents/skills/play_background_file/__init__.py +12 -0
  110. signalwire_agents/skills/play_background_file/skill.py +242 -0
  111. signalwire_agents/skills/registry.py +395 -40
  112. signalwire_agents/skills/spider/README.md +236 -0
  113. signalwire_agents/skills/spider/__init__.py +13 -0
  114. signalwire_agents/skills/spider/skill.py +598 -0
  115. signalwire_agents/skills/swml_transfer/README.md +395 -0
  116. signalwire_agents/skills/swml_transfer/__init__.py +10 -0
  117. signalwire_agents/skills/swml_transfer/skill.py +359 -0
  118. signalwire_agents/skills/weather_api/README.md +178 -0
  119. signalwire_agents/skills/weather_api/__init__.py +12 -0
  120. signalwire_agents/skills/weather_api/skill.py +191 -0
  121. signalwire_agents/skills/web_search/README.md +163 -0
  122. signalwire_agents/skills/web_search/__init__.py +9 -0
  123. signalwire_agents/skills/web_search/skill.py +586 -112
  124. signalwire_agents/skills/wikipedia_search/README.md +228 -0
  125. signalwire_agents/{core/state → skills/wikipedia_search}/__init__.py +5 -4
  126. signalwire_agents/skills/{wikipedia → wikipedia_search}/skill.py +33 -3
  127. signalwire_agents/web/__init__.py +17 -0
  128. signalwire_agents/web/web_service.py +559 -0
  129. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/sw-agent-init.1 +400 -0
  130. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/sw-search.1 +483 -0
  131. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/swaig-test.1 +308 -0
  132. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/METADATA +347 -215
  133. signalwire_agents-1.0.17.dev4.dist-info/RECORD +147 -0
  134. signalwire_agents-1.0.17.dev4.dist-info/entry_points.txt +6 -0
  135. signalwire_agents/core/state/file_state_manager.py +0 -219
  136. signalwire_agents/core/state/state_manager.py +0 -101
  137. signalwire_agents/skills/wikipedia/__init__.py +0 -9
  138. signalwire_agents-0.1.13.data/data/schema.json +0 -5611
  139. signalwire_agents-0.1.13.dist-info/RECORD +0 -67
  140. signalwire_agents-0.1.13.dist-info/entry_points.txt +0 -3
  141. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/WHEEL +0 -0
  142. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/licenses/LICENSE +0 -0
  143. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,564 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Copyright (c) 2025 SignalWire
4
+
5
+ This file is part of the SignalWire AI Agents SDK.
6
+
7
+ Licensed under the MIT License.
8
+ See LICENSE file in the project root for full license information.
9
+ """
10
+
11
+ """
12
+ MCP-SWAIG Gateway Service
13
+
14
+ HTTP/HTTPS server that bridges MCP servers with SignalWire SWAIG functions.
15
+ Manages sessions, handles authentication, and translates between protocols.
16
+ """
17
+
18
+ import os
19
+ import sys
20
+ import json
21
+ import logging
22
+ import argparse
23
+ import signal
24
+ import re
25
+ import hashlib
26
+ from typing import Dict, Any, Optional
27
+ from datetime import datetime
28
+ import base64
29
+ import ssl
30
+ import concurrent.futures
31
+
32
+ from flask import Flask, request, jsonify, Response
33
+ from werkzeug.serving import make_server
34
+ from flask_limiter import Limiter
35
+ from flask_limiter.util import get_remote_address
36
+ from functools import wraps
37
+ import threading
38
+
39
+ from signalwire_agents.mcp_gateway.session_manager import SessionManager
40
+ from signalwire_agents.mcp_gateway.mcp_manager import MCPManager
41
+ from signalwire_agents.core.config_loader import ConfigLoader
42
+ from signalwire_agents.core.security_config import SecurityConfig
43
+
44
+ # Configure logging
45
+ logging.basicConfig(
46
+ level=logging.INFO,
47
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
48
+ )
49
+ logger = logging.getLogger('gateway_service')
50
+
51
+
52
+ class MCPGateway:
53
+ """Main gateway service class"""
54
+
55
+ def __init__(self, config_path: str = "config.json"):
56
+ # Use unified config loader
57
+ self.config_loader = ConfigLoader([config_path])
58
+
59
+ # Load config with fallback to old method if needed
60
+ if self.config_loader.has_config():
61
+ # Use new config loader with variable substitution
62
+ self.config = self.config_loader.substitute_vars(self.config_loader.get_config())
63
+ logger.info(f"Loaded config using unified ConfigLoader from {config_path}")
64
+ else:
65
+ # Fall back to old method for backward compatibility
66
+ self.config = self._load_config(config_path)
67
+
68
+ # Load security configuration
69
+ self.security = SecurityConfig(config_file=config_path, service_name="mcp")
70
+ self.security.log_config("MCPGateway")
71
+
72
+ self.app = Flask(__name__)
73
+ self.mcp_manager = MCPManager(self.config)
74
+ self.session_manager = SessionManager(self.config)
75
+ self.server = None
76
+ self._shutdown_requested = False
77
+
78
+ # Configure rate limiting from config
79
+ self.rate_config = self.config.get('rate_limiting', {})
80
+ default_limits = self.rate_config.get('default_limits', ["200 per day", "50 per hour"])
81
+ storage_uri = self.rate_config.get('storage_uri', "memory://")
82
+
83
+ self.limiter = Limiter(
84
+ app=self.app,
85
+ key_func=get_remote_address,
86
+ default_limits=default_limits,
87
+ storage_uri=storage_uri
88
+ )
89
+
90
+ # Configure security headers
91
+ @self.app.after_request
92
+ def set_security_headers(response):
93
+ response.headers['X-Content-Type-Options'] = 'nosniff'
94
+ response.headers['X-Frame-Options'] = 'DENY'
95
+ response.headers['X-XSS-Protection'] = '1; mode=block'
96
+ response.headers['Content-Security-Policy'] = "default-src 'none'; frame-ancestors 'none';"
97
+ if request.is_secure:
98
+ response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
99
+ return response
100
+
101
+ # Configure request size limit (10MB)
102
+ self.app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
103
+
104
+ # Configure logging
105
+ log_config = self.config.get('logging', {})
106
+ log_level = getattr(logging, log_config.get('level', 'INFO').upper())
107
+ logging.getLogger().setLevel(log_level)
108
+
109
+ if log_config.get('file'):
110
+ file_handler = logging.FileHandler(log_config['file'])
111
+ file_handler.setFormatter(logging.Formatter(
112
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
113
+ ))
114
+ logging.getLogger().addHandler(file_handler)
115
+
116
+ # Set up routes
117
+ self._setup_routes()
118
+
119
+ # Validate services on startup
120
+ logger.info("Validating MCP services...")
121
+ validation_results = self.mcp_manager.validate_services()
122
+ for service, valid in validation_results.items():
123
+ if not valid:
124
+ logger.warning(f"Service '{service}' failed validation")
125
+
126
+ def _validate_service_name(self, name: str) -> str:
127
+ """Validate service name to prevent injection attacks"""
128
+ if not name or len(name) > 64:
129
+ raise ValueError("Invalid service name length")
130
+ if not re.match(r'^[a-zA-Z0-9_-]+$', name):
131
+ raise ValueError("Service name contains invalid characters")
132
+ return name
133
+
134
+ def _validate_session_id(self, session_id: str) -> str:
135
+ """Validate session ID format"""
136
+ if not session_id or len(session_id) > 128:
137
+ raise ValueError("Invalid session ID length")
138
+ if not re.match(r'^[a-zA-Z0-9_.-]+$', session_id):
139
+ raise ValueError("Session ID contains invalid characters")
140
+ return session_id
141
+
142
+ def _validate_tool_name(self, name: str) -> str:
143
+ """Validate tool name"""
144
+ if not name or len(name) > 64:
145
+ raise ValueError("Invalid tool name length")
146
+ if not re.match(r'^[a-zA-Z0-9_-]+$', name):
147
+ raise ValueError("Tool name contains invalid characters")
148
+ return name
149
+
150
+ def _log_security_event(self, event_type: str, details: Dict[str, Any]):
151
+ """Log security-relevant events with proper sanitization"""
152
+ # Sanitize any user input in details
153
+ sanitized = {}
154
+ for key, value in details.items():
155
+ if isinstance(value, str):
156
+ # Truncate long strings and remove control characters
157
+ sanitized[key] = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', str(value)[:256])
158
+ else:
159
+ sanitized[key] = value
160
+
161
+ # Add timestamp and event type
162
+ sanitized['event_type'] = event_type
163
+ sanitized['timestamp'] = datetime.now().isoformat()
164
+
165
+ # Log with SECURITY prefix for easy filtering
166
+ logger.info(f"SECURITY_EVENT: {json.dumps(sanitized)}")
167
+
168
+ def _substitute_env_vars(self, value: Any) -> Any:
169
+ """Recursively substitute environment variables in config values
170
+
171
+ Supports format: ${VAR_NAME|default_value}
172
+ """
173
+ if isinstance(value, str):
174
+ # Check for environment variable pattern
175
+ if value.startswith('${') and value.endswith('}'):
176
+ var_expr = value[2:-1]
177
+ if '|' in var_expr:
178
+ var_name, default = var_expr.split('|', 1)
179
+ return os.environ.get(var_name, default)
180
+ else:
181
+ # No default provided
182
+ return os.environ.get(var_expr, value)
183
+ return value
184
+ elif isinstance(value, dict):
185
+ return {k: self._substitute_env_vars(v) for k, v in value.items()}
186
+ elif isinstance(value, list):
187
+ return [self._substitute_env_vars(item) for item in value]
188
+ else:
189
+ return value
190
+
191
+ def _load_config(self, config_path: str) -> Dict[str, Any]:
192
+ """Load configuration from JSON file with environment variable substitution"""
193
+ if not os.path.exists(config_path):
194
+ # Check if sample_config.json exists
195
+ sample_path = "sample_config.json"
196
+ if os.path.exists(sample_path):
197
+ # Copy sample to config
198
+ import shutil
199
+ shutil.copy(sample_path, config_path)
200
+ logger.info(f"Created config.json from sample_config.json")
201
+ else:
202
+ # Create minimal default config if no sample exists
203
+ default_config = {
204
+ "server": {
205
+ "host": "0.0.0.0",
206
+ "port": 8080,
207
+ "auth_user": "admin",
208
+ "auth_password": "changeme"
209
+ },
210
+ "services": {
211
+ "todo": {
212
+ "command": ["python3", "./test/todo_mcp.py"],
213
+ "description": "Simple todo list for testing",
214
+ "enabled": True
215
+ }
216
+ },
217
+ "session": {
218
+ "default_timeout": 300,
219
+ "max_sessions_per_service": 100,
220
+ "cleanup_interval": 60
221
+ },
222
+ "rate_limiting": {
223
+ "default_limits": ["200 per day", "50 per hour"],
224
+ "tools_limit": "30 per minute",
225
+ "call_limit": "10 per minute",
226
+ "session_delete_limit": "20 per minute",
227
+ "storage_uri": "memory://"
228
+ },
229
+ "logging": {
230
+ "level": "INFO"
231
+ }
232
+ }
233
+
234
+ with open(config_path, 'w') as f:
235
+ json.dump(default_config, f, indent=2)
236
+
237
+ logger.info(f"Created default config at {config_path}")
238
+ return default_config
239
+
240
+ with open(config_path, 'r') as f:
241
+ config = json.load(f)
242
+
243
+ # Apply environment variable substitution
244
+ config = self._substitute_env_vars(config)
245
+
246
+ # Handle special cases for numeric values
247
+ # Port
248
+ if isinstance(config.get('server', {}).get('port'), str):
249
+ try:
250
+ config['server']['port'] = int(config['server']['port'])
251
+ except ValueError:
252
+ logger.warning(f"Invalid port value: {config['server']['port']}, using default 8080")
253
+ config['server']['port'] = 8080
254
+
255
+ # Session timeouts and limits
256
+ session_config = config.get('session', {})
257
+ for key in ['default_timeout', 'max_sessions_per_service', 'cleanup_interval']:
258
+ if key in session_config and isinstance(session_config[key], str):
259
+ try:
260
+ session_config[key] = int(session_config[key])
261
+ except ValueError:
262
+ logger.warning(f"Invalid {key} value: {session_config[key]}, using default")
263
+ # Set sensible defaults
264
+ defaults = {
265
+ 'default_timeout': 300,
266
+ 'max_sessions_per_service': 100,
267
+ 'cleanup_interval': 60
268
+ }
269
+ session_config[key] = defaults.get(key, 60)
270
+
271
+ return config
272
+
273
+ def _check_auth(self, f):
274
+ """Decorator for authentication using Bearer tokens or Basic auth"""
275
+ @wraps(f)
276
+ def decorated(*args, **kwargs):
277
+ # Try Bearer token first
278
+ auth_header = request.headers.get('Authorization', '')
279
+ server_config = self.config.get('server', {})
280
+
281
+ if auth_header.startswith('Bearer '):
282
+ token = auth_header[7:]
283
+ expected_token = server_config.get('auth_token')
284
+ if expected_token and token == expected_token:
285
+ return f(*args, **kwargs)
286
+
287
+ # Fall back to Basic auth
288
+ auth = request.authorization
289
+ if auth and auth.username == server_config.get('auth_user') and \
290
+ auth.password == server_config.get('auth_password'):
291
+ return f(*args, **kwargs)
292
+
293
+ # Log failed auth attempt
294
+ self._log_security_event('auth_failed', {
295
+ 'ip': request.remote_addr,
296
+ 'method': request.method,
297
+ 'path': request.path
298
+ })
299
+
300
+ return Response(
301
+ 'Authentication required',
302
+ 401,
303
+ {'WWW-Authenticate': 'Basic realm="MCP Gateway"'}
304
+ )
305
+
306
+ return decorated
307
+
308
+ def _setup_routes(self):
309
+ """Set up Flask routes"""
310
+
311
+ @self.app.route('/health', methods=['GET'])
312
+ def health():
313
+ """Health check endpoint"""
314
+ return jsonify({
315
+ "status": "healthy",
316
+ "timestamp": datetime.now().isoformat(),
317
+ "version": "1.0.0"
318
+ })
319
+
320
+ @self.app.route('/services', methods=['GET'])
321
+ @self._check_auth
322
+ def list_services():
323
+ """List available MCP services"""
324
+ services = self.mcp_manager.list_services()
325
+ return jsonify(services)
326
+
327
+ @self.app.route('/services/<service_name>/tools', methods=['GET'])
328
+ @self.limiter.limit(self.rate_config.get('tools_limit', "30 per minute"))
329
+ @self._check_auth
330
+ def get_service_tools(service_name):
331
+ """Get tools for a specific service"""
332
+ try:
333
+ # Validate input
334
+ service_name = self._validate_service_name(service_name)
335
+
336
+ tools = self.mcp_manager.get_service_tools(service_name)
337
+ return jsonify({"service": service_name, "tools": tools})
338
+ except ValueError as e:
339
+ return jsonify({"error": str(e)}), 400
340
+ except Exception as e:
341
+ logger.error(f"Error getting tools for {service_name}: {e}")
342
+ return jsonify({"error": "Service error"}), 500
343
+
344
+ @self.app.route('/services/<service_name>/call', methods=['POST'])
345
+ @self.limiter.limit(self.rate_config.get('call_limit', "10 per minute"))
346
+ @self._check_auth
347
+ def call_service_tool(service_name):
348
+ """Call a tool on a service"""
349
+ try:
350
+ # Validate service name
351
+ service_name = self._validate_service_name(service_name)
352
+
353
+ data = request.get_json()
354
+ if not data:
355
+ return jsonify({"error": "Invalid JSON"}), 400
356
+
357
+ # Validate tool name
358
+ tool_name = data.get('tool')
359
+ if not tool_name:
360
+ return jsonify({"error": "Missing 'tool' parameter"}), 400
361
+ tool_name = self._validate_tool_name(tool_name)
362
+
363
+ # Validate session ID
364
+ session_id = data.get('session_id')
365
+ if not session_id:
366
+ return jsonify({"error": "Missing 'session_id' parameter"}), 400
367
+ session_id = self._validate_session_id(session_id)
368
+
369
+ # Validate other parameters
370
+ arguments = data.get('arguments', {})
371
+ if not isinstance(arguments, dict):
372
+ return jsonify({"error": "Invalid 'arguments' parameter"}), 400
373
+
374
+ timeout = data.get('timeout', self.session_manager.default_timeout)
375
+ if not isinstance(timeout, (int, float)) or timeout <= 0 or timeout > 3600:
376
+ return jsonify({"error": "Invalid 'timeout' parameter"}), 400
377
+
378
+ metadata = data.get('metadata', {})
379
+ if not isinstance(metadata, dict):
380
+ return jsonify({"error": "Invalid 'metadata' parameter"}), 400
381
+
382
+ # Log the tool call
383
+ self._log_security_event('tool_call', {
384
+ 'service': service_name,
385
+ 'tool': tool_name,
386
+ 'session_id': session_id,
387
+ 'ip': request.remote_addr
388
+ })
389
+
390
+ # Get or create session
391
+ session = self.session_manager.get_session(session_id)
392
+
393
+ if not session:
394
+ # Create new session
395
+ logger.info(f"Creating new session {session_id} for service {service_name}")
396
+
397
+ try:
398
+ client = self.mcp_manager.create_client(service_name)
399
+ session = self.session_manager.create_session(
400
+ session_id=session_id,
401
+ service_name=service_name,
402
+ process=client,
403
+ timeout=timeout,
404
+ metadata=metadata
405
+ )
406
+ except Exception as e:
407
+ logger.error(f"Failed to create session: {e}")
408
+ return jsonify({"error": f"Failed to create session: {str(e)}"}), 500
409
+
410
+ elif session.service_name != service_name:
411
+ return jsonify({
412
+ "error": f"Session {session_id} is for service '{session.service_name}', not '{service_name}'"
413
+ }), 400
414
+
415
+ # Get the MCP client from the session
416
+ client = session.process
417
+
418
+ # Call the tool
419
+ logger.info(f"Calling {service_name}.{tool_name} for session {session_id}")
420
+ result = client.call_tool(tool_name, arguments)
421
+
422
+ # Extract text content if it's in MCP format
423
+ if isinstance(result, dict) and 'content' in result:
424
+ content = result['content']
425
+ if isinstance(content, list) and len(content) > 0:
426
+ if content[0].get('type') == 'text':
427
+ result = content[0].get('text', result)
428
+
429
+ return jsonify({
430
+ "session_id": session_id,
431
+ "service": service_name,
432
+ "tool": tool_name,
433
+ "result": result
434
+ })
435
+
436
+ except Exception as e:
437
+ logger.error(f"Error calling tool: {e}")
438
+ return jsonify({"error": str(e)}), 500
439
+
440
+ @self.app.route('/sessions', methods=['GET'])
441
+ @self._check_auth
442
+ def list_sessions():
443
+ """List active sessions"""
444
+ sessions = self.session_manager.list_sessions()
445
+ return jsonify(sessions)
446
+
447
+ @self.app.route('/sessions/<session_id>', methods=['DELETE'])
448
+ @self.limiter.limit(self.rate_config.get('session_delete_limit', "20 per minute"))
449
+ @self._check_auth
450
+ def close_session(session_id):
451
+ """Close a specific session"""
452
+ try:
453
+ # Validate session ID
454
+ session_id = self._validate_session_id(session_id)
455
+
456
+ if self.session_manager.close_session(session_id):
457
+ self._log_security_event('session_closed', {
458
+ 'session_id': session_id,
459
+ 'ip': request.remote_addr
460
+ })
461
+ return jsonify({"message": f"Session {session_id} closed"})
462
+ else:
463
+ return jsonify({"error": f"Session {session_id} not found"}), 404
464
+ except ValueError as e:
465
+ return jsonify({"error": str(e)}), 400
466
+
467
+ @self.app.errorhandler(Exception)
468
+ def handle_error(error):
469
+ logger.error(f"Unhandled error: {error}")
470
+ return jsonify({"error": "Internal server error"}), 500
471
+
472
+ def run(self):
473
+ """Run the gateway service"""
474
+ server_config = self.config.get('server', {})
475
+ host = server_config.get('host', '0.0.0.0')
476
+ port = server_config.get('port', 8080)
477
+
478
+ # Check for SSL certificate
479
+ ssl_cert_path = "certs/server.pem"
480
+ ssl_context = None
481
+ protocol = "http"
482
+
483
+ if os.path.exists(ssl_cert_path):
484
+ logger.info(f"Found SSL certificate at {ssl_cert_path}, enabling HTTPS")
485
+ ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
486
+ ssl_context.load_cert_chain(ssl_cert_path)
487
+ protocol = "https"
488
+
489
+ # Create server with threaded=True for better shutdown handling
490
+ self.server = make_server(host, port, self.app, ssl_context=ssl_context, threaded=True)
491
+
492
+ logger.info(f"MCP Gateway starting on {protocol}://{host}:{port}")
493
+ logger.info(f"Basic Auth - User: {server_config.get('auth_user')}")
494
+
495
+ # Set up signal handlers
496
+ signal.signal(signal.SIGTERM, self._signal_handler)
497
+ signal.signal(signal.SIGINT, self._signal_handler)
498
+
499
+ try:
500
+ self.server.serve_forever()
501
+ except KeyboardInterrupt:
502
+ logger.info("Received interrupt, shutting down...")
503
+ finally:
504
+ self.shutdown()
505
+
506
+ def _signal_handler(self, signum, frame):
507
+ """Handle shutdown signals"""
508
+ logger.info(f"Received signal {signum}")
509
+ self._shutdown_requested = True
510
+ # Request server shutdown in a thread to avoid blocking
511
+ if self.server:
512
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
513
+
514
+ def shutdown(self):
515
+ """Shutdown the gateway service"""
516
+ if self._shutdown_requested:
517
+ # Already shutting down
518
+ return
519
+
520
+ self._shutdown_requested = True
521
+ logger.info("Shutting down MCP Gateway...")
522
+
523
+ # Shutdown components in parallel for faster shutdown
524
+ import concurrent.futures
525
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
526
+ # Submit shutdown tasks
527
+ session_future = executor.submit(self.session_manager.shutdown)
528
+ mcp_future = executor.submit(self.mcp_manager.shutdown)
529
+
530
+ # Wait for completion with timeout
531
+ try:
532
+ session_future.result(timeout=5)
533
+ except concurrent.futures.TimeoutError:
534
+ logger.warning("Session manager shutdown timed out")
535
+
536
+ try:
537
+ mcp_future.result(timeout=5)
538
+ except concurrent.futures.TimeoutError:
539
+ logger.warning("MCP manager shutdown timed out")
540
+
541
+ # Shutdown server
542
+ if self.server and hasattr(self.server, 'shutdown'):
543
+ try:
544
+ self.server.shutdown()
545
+ except:
546
+ pass
547
+
548
+ logger.info("MCP Gateway shutdown complete")
549
+
550
+
551
+ def main():
552
+ """Main entry point"""
553
+ parser = argparse.ArgumentParser(description='MCP-SWAIG Gateway Service')
554
+ parser.add_argument('-c', '--config', default='config.json',
555
+ help='Path to configuration file (default: config.json)')
556
+
557
+ args = parser.parse_args()
558
+
559
+ gateway = MCPGateway(args.config)
560
+ gateway.run()
561
+
562
+
563
+ if __name__ == '__main__':
564
+ main()