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,513 @@
|
|
|
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 Server Manager for MCP Gateway
|
|
13
|
+
|
|
14
|
+
Handles spawning, communication, and management of MCP server processes.
|
|
15
|
+
Implements the MCP protocol client functionality.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import subprocess
|
|
21
|
+
import json
|
|
22
|
+
import threading
|
|
23
|
+
import queue
|
|
24
|
+
import logging
|
|
25
|
+
import time
|
|
26
|
+
import pwd
|
|
27
|
+
import tempfile
|
|
28
|
+
import shutil
|
|
29
|
+
import resource
|
|
30
|
+
import select
|
|
31
|
+
from typing import Dict, Any, List, Optional, Tuple
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class MCPService:
|
|
39
|
+
"""Configuration for an MCP service"""
|
|
40
|
+
name: str
|
|
41
|
+
command: List[str]
|
|
42
|
+
description: str
|
|
43
|
+
enabled: bool = True
|
|
44
|
+
sandbox_config: Dict[str, Any] = None
|
|
45
|
+
|
|
46
|
+
def __post_init__(self):
|
|
47
|
+
# Default sandbox config if not provided
|
|
48
|
+
if self.sandbox_config is None:
|
|
49
|
+
self.sandbox_config = {
|
|
50
|
+
'enabled': True,
|
|
51
|
+
'resource_limits': True,
|
|
52
|
+
'restricted_env': True
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
def __hash__(self):
|
|
56
|
+
return hash(self.name)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MCPClient:
|
|
60
|
+
"""Client for communicating with a single MCP server process"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, service: MCPService, sandbox_base_dir: str = './sandbox'):
|
|
63
|
+
self.service = service
|
|
64
|
+
self.process = None
|
|
65
|
+
self.request_id = 0
|
|
66
|
+
self.pending_requests = {}
|
|
67
|
+
self.response_queue = queue.Queue()
|
|
68
|
+
self.reader_thread = None
|
|
69
|
+
self.lock = threading.Lock()
|
|
70
|
+
self.tools = []
|
|
71
|
+
self.sandbox_base_dir = sandbox_base_dir
|
|
72
|
+
self.sandbox_dir = None
|
|
73
|
+
self._shutdown = threading.Event()
|
|
74
|
+
|
|
75
|
+
def _setup_sandbox_env(self) -> Tuple[Dict[str, str], Optional[str]]:
|
|
76
|
+
"""Create environment for the MCP process based on sandbox config
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Tuple of (environment dict, working directory)
|
|
80
|
+
"""
|
|
81
|
+
sandbox_config = self.service.sandbox_config
|
|
82
|
+
|
|
83
|
+
# Check if sandboxing is disabled
|
|
84
|
+
if not sandbox_config.get('enabled', True):
|
|
85
|
+
logger.info(f"Sandboxing disabled for '{self.service.name}'")
|
|
86
|
+
return os.environ.copy(), sandbox_config.get('working_dir', os.getcwd())
|
|
87
|
+
|
|
88
|
+
# Create a subdirectory in the sandbox base for this process
|
|
89
|
+
self.sandbox_dir = os.path.join(self.sandbox_base_dir, f"mcp_{self.service.name}_{os.getpid()}")
|
|
90
|
+
os.makedirs(self.sandbox_dir, exist_ok=True)
|
|
91
|
+
|
|
92
|
+
# Start with appropriate environment
|
|
93
|
+
if sandbox_config.get('restricted_env', True):
|
|
94
|
+
# Restricted environment
|
|
95
|
+
env = {
|
|
96
|
+
'PATH': os.environ.get('PATH', '/usr/bin:/bin'),
|
|
97
|
+
'HOME': self.sandbox_dir,
|
|
98
|
+
'TMPDIR': self.sandbox_dir,
|
|
99
|
+
'TEMP': self.sandbox_dir,
|
|
100
|
+
'TMP': self.sandbox_dir,
|
|
101
|
+
'USER': os.environ.get('USER', 'nobody'),
|
|
102
|
+
'LANG': 'C.UTF-8',
|
|
103
|
+
}
|
|
104
|
+
# Copy only essential env vars if they exist
|
|
105
|
+
for key in ['PYTHONPATH', 'NODE_PATH', 'JAVA_HOME']:
|
|
106
|
+
if key in os.environ:
|
|
107
|
+
env[key] = os.environ[key]
|
|
108
|
+
else:
|
|
109
|
+
# Full environment with some overrides
|
|
110
|
+
env = os.environ.copy()
|
|
111
|
+
env['HOME'] = self.sandbox_dir
|
|
112
|
+
env['TMPDIR'] = self.sandbox_dir
|
|
113
|
+
env['TEMP'] = self.sandbox_dir
|
|
114
|
+
env['TMP'] = self.sandbox_dir
|
|
115
|
+
|
|
116
|
+
# Always remove potentially dangerous env vars
|
|
117
|
+
dangerous_vars = ['LD_PRELOAD', 'LD_LIBRARY_PATH', 'DYLD_INSERT_LIBRARIES']
|
|
118
|
+
for var in dangerous_vars:
|
|
119
|
+
env.pop(var, None)
|
|
120
|
+
|
|
121
|
+
# Determine working directory
|
|
122
|
+
working_dir = sandbox_config.get('working_dir', os.getcwd())
|
|
123
|
+
|
|
124
|
+
return env, working_dir
|
|
125
|
+
|
|
126
|
+
def _sandbox_preexec(self):
|
|
127
|
+
"""Pre-exec function to sandbox the process"""
|
|
128
|
+
sandbox_config = self.service.sandbox_config
|
|
129
|
+
|
|
130
|
+
# Check if sandboxing is disabled
|
|
131
|
+
if not sandbox_config.get('enabled', True):
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Check if resource limits are enabled
|
|
135
|
+
if sandbox_config.get('resource_limits', True):
|
|
136
|
+
try:
|
|
137
|
+
# Set resource limits
|
|
138
|
+
# Limit CPU time (300 seconds)
|
|
139
|
+
resource.setrlimit(resource.RLIMIT_CPU, (300, 300))
|
|
140
|
+
# Limit memory (512MB)
|
|
141
|
+
resource.setrlimit(resource.RLIMIT_AS, (512 * 1024 * 1024, 512 * 1024 * 1024))
|
|
142
|
+
# Limit number of processes (10)
|
|
143
|
+
resource.setrlimit(resource.RLIMIT_NPROC, (10, 10))
|
|
144
|
+
# Limit file size (10MB)
|
|
145
|
+
resource.setrlimit(resource.RLIMIT_FSIZE, (10 * 1024 * 1024, 10 * 1024 * 1024))
|
|
146
|
+
except Exception as e:
|
|
147
|
+
# Log but don't fail - some systems might not support all operations
|
|
148
|
+
logger.warning(f"Resource limit warning: {e}")
|
|
149
|
+
|
|
150
|
+
# Drop privileges if running as root (currently disabled)
|
|
151
|
+
# NOTE: Disabled for now - needs more testing with MCP servers
|
|
152
|
+
# if os.getuid() == 0:
|
|
153
|
+
# nobody_uid = pwd.getpwnam('nobody').pw_uid
|
|
154
|
+
# os.setgroups([]) # Remove supplementary groups
|
|
155
|
+
# os.setgid(nobody_uid) # Set GID first
|
|
156
|
+
# os.setuid(nobody_uid) # Then UID
|
|
157
|
+
|
|
158
|
+
def start(self) -> bool:
|
|
159
|
+
"""Start the MCP server process and initialize connection"""
|
|
160
|
+
try:
|
|
161
|
+
logger.info(f"Starting MCP service '{self.service.name}' with command: {self.service.command}")
|
|
162
|
+
|
|
163
|
+
# Set up environment and working directory
|
|
164
|
+
env, working_dir = self._setup_sandbox_env()
|
|
165
|
+
|
|
166
|
+
# Log the command we're trying to run
|
|
167
|
+
logger.info(f"Starting command: {' '.join(self.service.command)}")
|
|
168
|
+
logger.info(f"Working directory: {working_dir}")
|
|
169
|
+
if self.sandbox_dir:
|
|
170
|
+
logger.info(f"Sandbox directory: {self.sandbox_dir}")
|
|
171
|
+
|
|
172
|
+
# Check if we should use preexec function
|
|
173
|
+
use_preexec = self.service.sandbox_config.get('enabled', True) and sys.platform != 'win32'
|
|
174
|
+
|
|
175
|
+
self.process = subprocess.Popen(
|
|
176
|
+
self.service.command,
|
|
177
|
+
stdin=subprocess.PIPE,
|
|
178
|
+
stdout=subprocess.PIPE,
|
|
179
|
+
stderr=subprocess.PIPE,
|
|
180
|
+
text=True,
|
|
181
|
+
bufsize=1, # Line buffered
|
|
182
|
+
cwd=working_dir,
|
|
183
|
+
env=env,
|
|
184
|
+
preexec_fn=self._sandbox_preexec if use_preexec else None
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Start reader thread
|
|
188
|
+
self.reader_thread = threading.Thread(target=self._read_loop, daemon=True)
|
|
189
|
+
self.reader_thread.start()
|
|
190
|
+
|
|
191
|
+
# Initialize the MCP session
|
|
192
|
+
result = self._initialize()
|
|
193
|
+
if not result:
|
|
194
|
+
logger.error(f"Failed to initialize MCP service '{self.service.name}'")
|
|
195
|
+
self.stop()
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
# Get available tools
|
|
199
|
+
self.tools = self._list_tools()
|
|
200
|
+
logger.info(f"MCP service '{self.service.name}' started with {len(self.tools)} tools")
|
|
201
|
+
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.error(f"Error starting MCP service '{self.service.name}': {e}")
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
def stop(self):
|
|
209
|
+
"""Stop the MCP server process and clean up sandbox"""
|
|
210
|
+
# Signal shutdown to reader thread
|
|
211
|
+
self._shutdown.set()
|
|
212
|
+
|
|
213
|
+
if self.process:
|
|
214
|
+
try:
|
|
215
|
+
# Try graceful shutdown first (with short timeout)
|
|
216
|
+
try:
|
|
217
|
+
# Send shutdown with very short timeout
|
|
218
|
+
with self.lock:
|
|
219
|
+
request_id = self.request_id
|
|
220
|
+
self.request_id += 1
|
|
221
|
+
request = {
|
|
222
|
+
"jsonrpc": "2.0",
|
|
223
|
+
"id": request_id,
|
|
224
|
+
"method": "shutdown",
|
|
225
|
+
"params": {}
|
|
226
|
+
}
|
|
227
|
+
self._send_message(request)
|
|
228
|
+
time.sleep(0.2) # Very brief wait
|
|
229
|
+
except:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
# Check if still running
|
|
233
|
+
if self.process.poll() is None:
|
|
234
|
+
# Terminate process
|
|
235
|
+
self.process.terminate()
|
|
236
|
+
try:
|
|
237
|
+
self.process.wait(timeout=1)
|
|
238
|
+
except subprocess.TimeoutExpired:
|
|
239
|
+
# Force kill if terminate didn't work
|
|
240
|
+
logger.warning(f"Force killing MCP service '{self.service.name}'")
|
|
241
|
+
self.process.kill()
|
|
242
|
+
self.process.wait(timeout=1)
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.error(f"Error stopping process: {e}")
|
|
246
|
+
# Last resort - force kill
|
|
247
|
+
try:
|
|
248
|
+
if self.process.poll() is None:
|
|
249
|
+
self.process.kill()
|
|
250
|
+
except:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
self.process = None
|
|
254
|
+
logger.info(f"Stopped MCP service '{self.service.name}'")
|
|
255
|
+
|
|
256
|
+
# Wait for reader thread to finish (with timeout)
|
|
257
|
+
if self.reader_thread and self.reader_thread.is_alive():
|
|
258
|
+
self.reader_thread.join(timeout=1.0)
|
|
259
|
+
if self.reader_thread.is_alive():
|
|
260
|
+
logger.warning(f"Reader thread for '{self.service.name}' did not stop gracefully")
|
|
261
|
+
|
|
262
|
+
# Clean up sandbox directory
|
|
263
|
+
if hasattr(self, 'sandbox_dir') and self.sandbox_dir and os.path.exists(self.sandbox_dir):
|
|
264
|
+
try:
|
|
265
|
+
shutil.rmtree(self.sandbox_dir)
|
|
266
|
+
logger.debug(f"Cleaned up sandbox directory: {self.sandbox_dir}")
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.warning(f"Failed to clean up sandbox directory: {e}")
|
|
269
|
+
|
|
270
|
+
def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
271
|
+
"""Call a tool on the MCP server"""
|
|
272
|
+
return self.call_method("tools/call", {
|
|
273
|
+
"name": tool_name,
|
|
274
|
+
"arguments": arguments
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
def call_method(self, method: str, params: Dict[str, Any]) -> Any:
|
|
278
|
+
"""Call an RPC method and wait for response"""
|
|
279
|
+
if self._shutdown.is_set():
|
|
280
|
+
raise RuntimeError("Client is shutting down")
|
|
281
|
+
|
|
282
|
+
with self.lock:
|
|
283
|
+
request_id = self.request_id
|
|
284
|
+
self.request_id += 1
|
|
285
|
+
|
|
286
|
+
request = {
|
|
287
|
+
"jsonrpc": "2.0",
|
|
288
|
+
"id": request_id,
|
|
289
|
+
"method": method,
|
|
290
|
+
"params": params
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
# Create future for response
|
|
294
|
+
future = threading.Event()
|
|
295
|
+
self.pending_requests[request_id] = {"event": future, "response": None}
|
|
296
|
+
|
|
297
|
+
# Send request
|
|
298
|
+
self._send_message(request)
|
|
299
|
+
|
|
300
|
+
# Wait for response (with timeout)
|
|
301
|
+
if not future.wait(timeout=30):
|
|
302
|
+
with self.lock:
|
|
303
|
+
self.pending_requests.pop(request_id, None)
|
|
304
|
+
raise TimeoutError(f"Timeout waiting for response to {method}")
|
|
305
|
+
|
|
306
|
+
# Get response
|
|
307
|
+
with self.lock:
|
|
308
|
+
response_data = self.pending_requests.pop(request_id)
|
|
309
|
+
response = response_data["response"]
|
|
310
|
+
|
|
311
|
+
if "error" in response:
|
|
312
|
+
raise Exception(f"MCP Error: {response['error']}")
|
|
313
|
+
|
|
314
|
+
return response.get("result")
|
|
315
|
+
|
|
316
|
+
def get_tools(self) -> List[Dict[str, Any]]:
|
|
317
|
+
"""Get the list of available tools"""
|
|
318
|
+
return self.tools.copy()
|
|
319
|
+
|
|
320
|
+
def _send_message(self, message: Dict[str, Any]):
|
|
321
|
+
"""Send a JSON-RPC message to the server"""
|
|
322
|
+
if not self.process or self.process.poll() is not None:
|
|
323
|
+
raise RuntimeError("MCP server process is not running")
|
|
324
|
+
|
|
325
|
+
json_str = json.dumps(message)
|
|
326
|
+
self.process.stdin.write(json_str + '\n')
|
|
327
|
+
self.process.stdin.flush()
|
|
328
|
+
logger.debug(f"Sent to '{self.service.name}': {json_str}")
|
|
329
|
+
|
|
330
|
+
def _read_loop(self):
|
|
331
|
+
"""Background thread to read responses from the MCP server"""
|
|
332
|
+
while not self._shutdown.is_set() and self.process and self.process.poll() is None:
|
|
333
|
+
try:
|
|
334
|
+
# Simple blocking read - the thread will be interrupted on shutdown
|
|
335
|
+
line = self.process.stdout.readline()
|
|
336
|
+
if not line:
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
message = json.loads(line.strip())
|
|
341
|
+
logger.debug(f"Received from '{self.service.name}': {message}")
|
|
342
|
+
|
|
343
|
+
# Handle response
|
|
344
|
+
if "id" in message:
|
|
345
|
+
request_id = message["id"]
|
|
346
|
+
with self.lock:
|
|
347
|
+
if request_id in self.pending_requests:
|
|
348
|
+
self.pending_requests[request_id]["response"] = message
|
|
349
|
+
self.pending_requests[request_id]["event"].set()
|
|
350
|
+
else:
|
|
351
|
+
# Notification - log it
|
|
352
|
+
logger.info(f"Notification from '{self.service.name}': {message}")
|
|
353
|
+
|
|
354
|
+
except json.JSONDecodeError as e:
|
|
355
|
+
logger.error(f"Invalid JSON from '{self.service.name}': {line.strip()}")
|
|
356
|
+
|
|
357
|
+
except Exception as e:
|
|
358
|
+
if not self._shutdown.is_set():
|
|
359
|
+
logger.error(f"Error in read loop for '{self.service.name}': {e}")
|
|
360
|
+
break
|
|
361
|
+
|
|
362
|
+
# Check if process died and capture any errors
|
|
363
|
+
if self.process and self.process.poll() is not None and not self._shutdown.is_set():
|
|
364
|
+
try:
|
|
365
|
+
stderr_output = self.process.stderr.read()
|
|
366
|
+
if stderr_output:
|
|
367
|
+
logger.error(f"Process '{self.service.name}' exited with code {self.process.returncode}")
|
|
368
|
+
logger.error(f"Stderr: {stderr_output}")
|
|
369
|
+
except:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
logger.info(f"Read loop ended for '{self.service.name}'")
|
|
373
|
+
|
|
374
|
+
def _initialize(self) -> bool:
|
|
375
|
+
"""Initialize the MCP session"""
|
|
376
|
+
try:
|
|
377
|
+
result = self.call_method("initialize", {
|
|
378
|
+
"protocolVersion": "2025-03-26",
|
|
379
|
+
"capabilities": {},
|
|
380
|
+
"clientInfo": {
|
|
381
|
+
"name": "mcp-gateway",
|
|
382
|
+
"version": "1.0.0"
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
logger.info(f"Initialized '{self.service.name}': {result.get('serverInfo', {})}")
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
except Exception as e:
|
|
390
|
+
logger.error(f"Failed to initialize '{self.service.name}': {e}")
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
def _list_tools(self) -> List[Dict[str, Any]]:
|
|
394
|
+
"""Get the list of available tools from the server"""
|
|
395
|
+
try:
|
|
396
|
+
result = self.call_method("tools/list", {})
|
|
397
|
+
return result.get("tools", [])
|
|
398
|
+
|
|
399
|
+
except Exception as e:
|
|
400
|
+
logger.error(f"Failed to list tools for '{self.service.name}': {e}")
|
|
401
|
+
return []
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class MCPManager:
|
|
405
|
+
"""Manages multiple MCP services and their lifecycles"""
|
|
406
|
+
|
|
407
|
+
def __init__(self, config: Dict[str, Any]):
|
|
408
|
+
self.config = config
|
|
409
|
+
self.services: Dict[str, MCPService] = {}
|
|
410
|
+
self.clients: Dict[str, MCPClient] = {}
|
|
411
|
+
|
|
412
|
+
# Get sandbox directory from config or use default
|
|
413
|
+
self.sandbox_base_dir = config.get('session', {}).get('sandbox_dir', './sandbox')
|
|
414
|
+
|
|
415
|
+
# Ensure sandbox directory exists
|
|
416
|
+
os.makedirs(self.sandbox_base_dir, exist_ok=True)
|
|
417
|
+
logger.info(f"Using sandbox base directory: {self.sandbox_base_dir}")
|
|
418
|
+
|
|
419
|
+
# Load services from config
|
|
420
|
+
self._load_services()
|
|
421
|
+
|
|
422
|
+
def _load_services(self):
|
|
423
|
+
"""Load service definitions from configuration"""
|
|
424
|
+
services_config = self.config.get('services', {})
|
|
425
|
+
|
|
426
|
+
for name, service_data in services_config.items():
|
|
427
|
+
if not service_data.get('enabled', True):
|
|
428
|
+
logger.info(f"Service '{name}' is disabled, skipping")
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
service = MCPService(
|
|
432
|
+
name=name,
|
|
433
|
+
command=service_data['command'],
|
|
434
|
+
description=service_data.get('description', ''),
|
|
435
|
+
enabled=service_data.get('enabled', True),
|
|
436
|
+
sandbox_config=service_data.get('sandbox', None)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
self.services[name] = service
|
|
440
|
+
logger.info(f"Loaded service '{name}': {service.description}")
|
|
441
|
+
|
|
442
|
+
def get_service(self, service_name: str) -> Optional[MCPService]:
|
|
443
|
+
"""Get a service definition by name"""
|
|
444
|
+
return self.services.get(service_name)
|
|
445
|
+
|
|
446
|
+
def list_services(self) -> Dict[str, Dict[str, Any]]:
|
|
447
|
+
"""List all available services"""
|
|
448
|
+
result = {}
|
|
449
|
+
|
|
450
|
+
for name, service in self.services.items():
|
|
451
|
+
result[name] = {
|
|
452
|
+
'description': service.description,
|
|
453
|
+
'enabled': service.enabled,
|
|
454
|
+
'command': service.command
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return result
|
|
458
|
+
|
|
459
|
+
def create_client(self, service_name: str) -> MCPClient:
|
|
460
|
+
"""Create a new MCP client for a service"""
|
|
461
|
+
service = self.services.get(service_name)
|
|
462
|
+
if not service:
|
|
463
|
+
raise ValueError(f"Unknown service: {service_name}")
|
|
464
|
+
|
|
465
|
+
if not service.enabled:
|
|
466
|
+
raise ValueError(f"Service '{service_name}' is disabled")
|
|
467
|
+
|
|
468
|
+
client = MCPClient(service, self.sandbox_base_dir)
|
|
469
|
+
if not client.start():
|
|
470
|
+
raise RuntimeError(f"Failed to start MCP service '{service_name}'")
|
|
471
|
+
|
|
472
|
+
# Track active client for cleanup
|
|
473
|
+
self.clients[f"{service_name}_{id(client)}"] = client
|
|
474
|
+
|
|
475
|
+
return client
|
|
476
|
+
|
|
477
|
+
def get_service_tools(self, service_name: str) -> List[Dict[str, Any]]:
|
|
478
|
+
"""Get tools for a service by starting a temporary instance"""
|
|
479
|
+
client = None
|
|
480
|
+
try:
|
|
481
|
+
client = self.create_client(service_name)
|
|
482
|
+
return client.get_tools()
|
|
483
|
+
finally:
|
|
484
|
+
if client:
|
|
485
|
+
client.stop()
|
|
486
|
+
|
|
487
|
+
def validate_services(self) -> Dict[str, bool]:
|
|
488
|
+
"""Validate that all services can be started"""
|
|
489
|
+
results = {}
|
|
490
|
+
|
|
491
|
+
for service_name in self.services:
|
|
492
|
+
try:
|
|
493
|
+
client = self.create_client(service_name)
|
|
494
|
+
client.stop()
|
|
495
|
+
results[service_name] = True
|
|
496
|
+
logger.info(f"Service '{service_name}' validation: OK")
|
|
497
|
+
except Exception as e:
|
|
498
|
+
results[service_name] = False
|
|
499
|
+
logger.error(f"Service '{service_name}' validation failed: {e}")
|
|
500
|
+
|
|
501
|
+
return results
|
|
502
|
+
|
|
503
|
+
def shutdown(self):
|
|
504
|
+
"""Shutdown all active MCP clients"""
|
|
505
|
+
logger.info(f"Shutting down {len(self.clients)} active MCP clients")
|
|
506
|
+
|
|
507
|
+
# Stop all clients
|
|
508
|
+
for client_id, client in list(self.clients.items()):
|
|
509
|
+
try:
|
|
510
|
+
client.stop()
|
|
511
|
+
self.clients.pop(client_id, None)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
logger.error(f"Error stopping client {client_id}: {e}")
|