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.
- signalwire_agents/__init__.py +99 -15
- signalwire_agents/agent_server.py +248 -60
- signalwire_agents/agents/bedrock.py +296 -0
- signalwire_agents/cli/__init__.py +9 -0
- signalwire_agents/cli/build_search.py +951 -41
- signalwire_agents/cli/config.py +80 -0
- signalwire_agents/cli/core/__init__.py +10 -0
- signalwire_agents/cli/core/agent_loader.py +470 -0
- signalwire_agents/cli/core/argparse_helpers.py +179 -0
- signalwire_agents/cli/core/dynamic_config.py +71 -0
- signalwire_agents/cli/core/service_loader.py +303 -0
- signalwire_agents/cli/dokku.py +2320 -0
- signalwire_agents/cli/execution/__init__.py +10 -0
- signalwire_agents/cli/execution/datamap_exec.py +446 -0
- signalwire_agents/cli/execution/webhook_exec.py +134 -0
- signalwire_agents/cli/init_project.py +2636 -0
- signalwire_agents/cli/output/__init__.py +10 -0
- signalwire_agents/cli/output/output_formatter.py +255 -0
- signalwire_agents/cli/output/swml_dump.py +186 -0
- signalwire_agents/cli/simulation/__init__.py +10 -0
- signalwire_agents/cli/simulation/data_generation.py +374 -0
- signalwire_agents/cli/simulation/data_overrides.py +200 -0
- signalwire_agents/cli/simulation/mock_env.py +282 -0
- signalwire_agents/cli/swaig_test_wrapper.py +52 -0
- signalwire_agents/cli/test_swaig.py +566 -2366
- signalwire_agents/cli/types.py +81 -0
- signalwire_agents/core/__init__.py +2 -2
- signalwire_agents/core/agent/__init__.py +12 -0
- signalwire_agents/core/agent/config/__init__.py +12 -0
- signalwire_agents/core/agent/deployment/__init__.py +9 -0
- signalwire_agents/core/agent/deployment/handlers/__init__.py +9 -0
- signalwire_agents/core/agent/prompt/__init__.py +14 -0
- signalwire_agents/core/agent/prompt/manager.py +306 -0
- signalwire_agents/core/agent/routing/__init__.py +9 -0
- signalwire_agents/core/agent/security/__init__.py +9 -0
- signalwire_agents/core/agent/swml/__init__.py +9 -0
- signalwire_agents/core/agent/tools/__init__.py +15 -0
- signalwire_agents/core/agent/tools/decorator.py +97 -0
- signalwire_agents/core/agent/tools/registry.py +210 -0
- signalwire_agents/core/agent_base.py +845 -2916
- signalwire_agents/core/auth_handler.py +233 -0
- signalwire_agents/core/config_loader.py +259 -0
- signalwire_agents/core/contexts.py +418 -0
- signalwire_agents/core/data_map.py +3 -15
- signalwire_agents/core/function_result.py +116 -44
- signalwire_agents/core/logging_config.py +162 -18
- signalwire_agents/core/mixins/__init__.py +28 -0
- signalwire_agents/core/mixins/ai_config_mixin.py +442 -0
- signalwire_agents/core/mixins/auth_mixin.py +280 -0
- signalwire_agents/core/mixins/prompt_mixin.py +358 -0
- signalwire_agents/core/mixins/serverless_mixin.py +460 -0
- signalwire_agents/core/mixins/skill_mixin.py +55 -0
- signalwire_agents/core/mixins/state_mixin.py +153 -0
- signalwire_agents/core/mixins/tool_mixin.py +230 -0
- signalwire_agents/core/mixins/web_mixin.py +1142 -0
- signalwire_agents/core/security_config.py +333 -0
- signalwire_agents/core/skill_base.py +84 -1
- signalwire_agents/core/skill_manager.py +62 -20
- signalwire_agents/core/swaig_function.py +18 -5
- signalwire_agents/core/swml_builder.py +207 -11
- signalwire_agents/core/swml_handler.py +27 -21
- signalwire_agents/core/swml_renderer.py +123 -312
- signalwire_agents/core/swml_service.py +171 -203
- signalwire_agents/mcp_gateway/__init__.py +29 -0
- signalwire_agents/mcp_gateway/gateway_service.py +564 -0
- signalwire_agents/mcp_gateway/mcp_manager.py +513 -0
- signalwire_agents/mcp_gateway/session_manager.py +218 -0
- signalwire_agents/prefabs/concierge.py +0 -3
- signalwire_agents/prefabs/faq_bot.py +0 -3
- signalwire_agents/prefabs/info_gatherer.py +0 -3
- signalwire_agents/prefabs/receptionist.py +0 -3
- signalwire_agents/prefabs/survey.py +0 -3
- signalwire_agents/schema.json +9218 -5489
- signalwire_agents/search/__init__.py +7 -1
- signalwire_agents/search/document_processor.py +490 -31
- signalwire_agents/search/index_builder.py +307 -37
- signalwire_agents/search/migration.py +418 -0
- signalwire_agents/search/models.py +30 -0
- signalwire_agents/search/pgvector_backend.py +748 -0
- signalwire_agents/search/query_processor.py +162 -31
- signalwire_agents/search/search_engine.py +916 -35
- signalwire_agents/search/search_service.py +376 -53
- signalwire_agents/skills/README.md +452 -0
- signalwire_agents/skills/__init__.py +14 -2
- signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
- signalwire_agents/skills/api_ninjas_trivia/__init__.py +12 -0
- signalwire_agents/skills/api_ninjas_trivia/skill.py +237 -0
- signalwire_agents/skills/datasphere/README.md +210 -0
- signalwire_agents/skills/datasphere/skill.py +84 -3
- signalwire_agents/skills/datasphere_serverless/README.md +258 -0
- signalwire_agents/skills/datasphere_serverless/__init__.py +9 -0
- signalwire_agents/skills/datasphere_serverless/skill.py +82 -1
- signalwire_agents/skills/datetime/README.md +132 -0
- signalwire_agents/skills/datetime/__init__.py +9 -0
- signalwire_agents/skills/datetime/skill.py +20 -7
- signalwire_agents/skills/joke/README.md +149 -0
- signalwire_agents/skills/joke/__init__.py +9 -0
- signalwire_agents/skills/joke/skill.py +21 -0
- signalwire_agents/skills/math/README.md +161 -0
- signalwire_agents/skills/math/__init__.py +9 -0
- signalwire_agents/skills/math/skill.py +18 -4
- signalwire_agents/skills/mcp_gateway/README.md +230 -0
- signalwire_agents/skills/mcp_gateway/__init__.py +10 -0
- signalwire_agents/skills/mcp_gateway/skill.py +421 -0
- signalwire_agents/skills/native_vector_search/README.md +210 -0
- signalwire_agents/skills/native_vector_search/__init__.py +9 -0
- signalwire_agents/skills/native_vector_search/skill.py +569 -101
- signalwire_agents/skills/play_background_file/README.md +218 -0
- signalwire_agents/skills/play_background_file/__init__.py +12 -0
- signalwire_agents/skills/play_background_file/skill.py +242 -0
- signalwire_agents/skills/registry.py +395 -40
- signalwire_agents/skills/spider/README.md +236 -0
- signalwire_agents/skills/spider/__init__.py +13 -0
- signalwire_agents/skills/spider/skill.py +598 -0
- signalwire_agents/skills/swml_transfer/README.md +395 -0
- signalwire_agents/skills/swml_transfer/__init__.py +10 -0
- signalwire_agents/skills/swml_transfer/skill.py +359 -0
- signalwire_agents/skills/weather_api/README.md +178 -0
- signalwire_agents/skills/weather_api/__init__.py +12 -0
- signalwire_agents/skills/weather_api/skill.py +191 -0
- signalwire_agents/skills/web_search/README.md +163 -0
- signalwire_agents/skills/web_search/__init__.py +9 -0
- signalwire_agents/skills/web_search/skill.py +586 -112
- signalwire_agents/skills/wikipedia_search/README.md +228 -0
- signalwire_agents/{core/state → skills/wikipedia_search}/__init__.py +5 -4
- signalwire_agents/skills/{wikipedia → wikipedia_search}/skill.py +33 -3
- signalwire_agents/web/__init__.py +17 -0
- signalwire_agents/web/web_service.py +559 -0
- signalwire_agents-1.0.17.dev4.data/data/share/man/man1/sw-agent-init.1 +400 -0
- signalwire_agents-1.0.17.dev4.data/data/share/man/man1/sw-search.1 +483 -0
- signalwire_agents-1.0.17.dev4.data/data/share/man/man1/swaig-test.1 +308 -0
- {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/METADATA +347 -215
- signalwire_agents-1.0.17.dev4.dist-info/RECORD +147 -0
- signalwire_agents-1.0.17.dev4.dist-info/entry_points.txt +6 -0
- signalwire_agents/core/state/file_state_manager.py +0 -219
- signalwire_agents/core/state/state_manager.py +0 -101
- signalwire_agents/skills/wikipedia/__init__.py +0 -9
- signalwire_agents-0.1.13.data/data/schema.json +0 -5611
- signalwire_agents-0.1.13.dist-info/RECORD +0 -67
- signalwire_agents-0.1.13.dist-info/entry_points.txt +0 -3
- {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/WHEEL +0 -0
- {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/licenses/LICENSE +0 -0
- {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()
|