mseep-lightfast-mcp 0.0.1__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.
- common/__init__.py +21 -0
- common/types.py +182 -0
- lightfast_mcp/__init__.py +50 -0
- lightfast_mcp/core/__init__.py +14 -0
- lightfast_mcp/core/base_server.py +205 -0
- lightfast_mcp/exceptions.py +55 -0
- lightfast_mcp/servers/__init__.py +1 -0
- lightfast_mcp/servers/blender/__init__.py +5 -0
- lightfast_mcp/servers/blender/server.py +358 -0
- lightfast_mcp/servers/blender_mcp_server.py +82 -0
- lightfast_mcp/servers/mock/__init__.py +5 -0
- lightfast_mcp/servers/mock/server.py +101 -0
- lightfast_mcp/servers/mock/tools.py +161 -0
- lightfast_mcp/servers/mock_server.py +78 -0
- lightfast_mcp/utils/__init__.py +1 -0
- lightfast_mcp/utils/logging_utils.py +69 -0
- mseep_lightfast_mcp-0.0.1.dist-info/METADATA +36 -0
- mseep_lightfast_mcp-0.0.1.dist-info/RECORD +43 -0
- mseep_lightfast_mcp-0.0.1.dist-info/WHEEL +5 -0
- mseep_lightfast_mcp-0.0.1.dist-info/entry_points.txt +7 -0
- mseep_lightfast_mcp-0.0.1.dist-info/licenses/LICENSE +21 -0
- mseep_lightfast_mcp-0.0.1.dist-info/top_level.txt +3 -0
- tools/__init__.py +46 -0
- tools/ai/__init__.py +8 -0
- tools/ai/conversation_cli.py +345 -0
- tools/ai/conversation_client.py +399 -0
- tools/ai/conversation_session.py +342 -0
- tools/ai/providers/__init__.py +11 -0
- tools/ai/providers/base_provider.py +64 -0
- tools/ai/providers/claude_provider.py +200 -0
- tools/ai/providers/openai_provider.py +204 -0
- tools/ai/tool_executor.py +257 -0
- tools/common/__init__.py +99 -0
- tools/common/async_utils.py +419 -0
- tools/common/errors.py +222 -0
- tools/common/logging.py +252 -0
- tools/common/types.py +130 -0
- tools/orchestration/__init__.py +15 -0
- tools/orchestration/cli.py +320 -0
- tools/orchestration/config_loader.py +348 -0
- tools/orchestration/server_orchestrator.py +466 -0
- tools/orchestration/server_registry.py +187 -0
- tools/orchestration/server_selector.py +242 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Server orchestrator for managing multiple MCP servers simultaneously."""
|
|
2
|
+
|
|
3
|
+
import signal
|
|
4
|
+
import subprocess
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from lightfast_mcp.core.base_server import BaseServer, ServerConfig
|
|
11
|
+
from tools.common import (
|
|
12
|
+
OperationStatus,
|
|
13
|
+
Result,
|
|
14
|
+
RetryManager,
|
|
15
|
+
ServerInfo,
|
|
16
|
+
ServerStartupError,
|
|
17
|
+
ServerState,
|
|
18
|
+
get_logger,
|
|
19
|
+
run_concurrent_operations,
|
|
20
|
+
with_correlation_id,
|
|
21
|
+
with_operation_context,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .server_registry import get_registry
|
|
25
|
+
|
|
26
|
+
logger = get_logger("ServerOrchestrator")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ServerProcess:
|
|
31
|
+
"""Information about a running server process."""
|
|
32
|
+
|
|
33
|
+
server: Optional[BaseServer] = None
|
|
34
|
+
thread: Optional[threading.Thread] = None
|
|
35
|
+
process: Optional[subprocess.Popen] = None
|
|
36
|
+
process_id: Optional[int] = None
|
|
37
|
+
start_time: datetime = field(default_factory=datetime.utcnow)
|
|
38
|
+
is_background: bool = False
|
|
39
|
+
config: Optional[ServerConfig] = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def uptime_seconds(self) -> float:
|
|
43
|
+
return (datetime.utcnow() - self.start_time).total_seconds()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ServerOrchestrator:
|
|
47
|
+
"""Orchestrates lifecycle and coordination of multiple MCP servers."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, max_concurrent_startups: int = 3):
|
|
50
|
+
"""Initialize the server orchestrator."""
|
|
51
|
+
self.registry = get_registry()
|
|
52
|
+
self._running_servers: Dict[str, ServerProcess] = {}
|
|
53
|
+
self._shutdown_event = threading.Event()
|
|
54
|
+
self.max_concurrent_startups = max_concurrent_startups
|
|
55
|
+
self.retry_manager = RetryManager(max_attempts=3, base_delay=2.0)
|
|
56
|
+
|
|
57
|
+
# Setup signal handlers for graceful shutdown
|
|
58
|
+
self._setup_signal_handlers()
|
|
59
|
+
|
|
60
|
+
def _setup_signal_handlers(self):
|
|
61
|
+
"""Setup signal handlers for graceful shutdown."""
|
|
62
|
+
|
|
63
|
+
def signal_handler(signum, frame):
|
|
64
|
+
logger.info(f"Received signal {signum}, initiating graceful shutdown")
|
|
65
|
+
self.shutdown_all()
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
69
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
70
|
+
except ValueError:
|
|
71
|
+
# Signal handling might not work in some environments (like Jupyter)
|
|
72
|
+
logger.debug("Could not setup signal handlers")
|
|
73
|
+
|
|
74
|
+
@with_correlation_id
|
|
75
|
+
@with_operation_context(operation="start_server")
|
|
76
|
+
async def start_server(
|
|
77
|
+
self,
|
|
78
|
+
server_config: ServerConfig,
|
|
79
|
+
background: bool = False,
|
|
80
|
+
show_logs: bool = True,
|
|
81
|
+
) -> Result[ServerInfo]:
|
|
82
|
+
"""Start a single server with proper error handling."""
|
|
83
|
+
if server_config.name in self._running_servers:
|
|
84
|
+
return Result(
|
|
85
|
+
status=OperationStatus.FAILED,
|
|
86
|
+
error=f"Server {server_config.name} is already running",
|
|
87
|
+
error_code="SERVER_ALREADY_RUNNING",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Validate configuration
|
|
91
|
+
server_type = server_config.config.get("type", "unknown")
|
|
92
|
+
is_valid, error_msg = self.registry.validate_server_config(
|
|
93
|
+
server_type, server_config
|
|
94
|
+
)
|
|
95
|
+
if not is_valid:
|
|
96
|
+
return Result(
|
|
97
|
+
status=OperationStatus.FAILED,
|
|
98
|
+
error=f"Invalid configuration: {error_msg}",
|
|
99
|
+
error_code="INVALID_CONFIG",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Choose startup method based on transport
|
|
104
|
+
if server_config.transport in ["http", "streamable-http"]:
|
|
105
|
+
result = await self._start_server_subprocess(
|
|
106
|
+
server_config, background, show_logs
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
result = await self._start_server_traditional(server_config, background)
|
|
110
|
+
|
|
111
|
+
if result.is_success:
|
|
112
|
+
logger.info(
|
|
113
|
+
f"Successfully started server: {server_config.name}",
|
|
114
|
+
server_name=server_config.name,
|
|
115
|
+
transport=server_config.transport,
|
|
116
|
+
background=background,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
error = ServerStartupError(
|
|
123
|
+
f"Failed to start server {server_config.name}",
|
|
124
|
+
server_name=server_config.name,
|
|
125
|
+
cause=e,
|
|
126
|
+
)
|
|
127
|
+
logger.error("Server startup failed", error=error)
|
|
128
|
+
return Result(
|
|
129
|
+
status=OperationStatus.FAILED,
|
|
130
|
+
error=str(error),
|
|
131
|
+
error_code=error.error_code,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def _start_server_subprocess(
|
|
135
|
+
self, server_config: ServerConfig, background: bool, show_logs: bool = True
|
|
136
|
+
) -> Result[ServerInfo]:
|
|
137
|
+
"""Start a server using subprocess (for HTTP transports)."""
|
|
138
|
+
try:
|
|
139
|
+
# Determine the correct script based on server type
|
|
140
|
+
server_type = server_config.config.get("type", "unknown")
|
|
141
|
+
|
|
142
|
+
# Validated list of trusted server modules (security: only allow known modules)
|
|
143
|
+
trusted_modules = {
|
|
144
|
+
"mock": "lightfast_mcp.servers.mock_server",
|
|
145
|
+
"blender": "lightfast_mcp.servers.blender_mcp_server",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if server_type not in trusted_modules:
|
|
149
|
+
return Result(
|
|
150
|
+
status=OperationStatus.FAILED,
|
|
151
|
+
error=f"Unknown or untrusted server type for subprocess: {server_type}",
|
|
152
|
+
error_code="UNTRUSTED_SERVER_TYPE",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
script_module = trusted_modules[server_type]
|
|
156
|
+
|
|
157
|
+
# Create environment with server config
|
|
158
|
+
import json
|
|
159
|
+
import os
|
|
160
|
+
import shutil
|
|
161
|
+
import subprocess
|
|
162
|
+
import sys
|
|
163
|
+
|
|
164
|
+
env = os.environ.copy()
|
|
165
|
+
env["LIGHTFAST_MCP_SERVER_CONFIG"] = json.dumps(
|
|
166
|
+
{
|
|
167
|
+
"name": server_config.name,
|
|
168
|
+
"description": server_config.description,
|
|
169
|
+
"host": server_config.host,
|
|
170
|
+
"port": server_config.port,
|
|
171
|
+
"transport": server_config.transport,
|
|
172
|
+
"path": server_config.path,
|
|
173
|
+
"config": server_config.config,
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Start the subprocess
|
|
178
|
+
# Security: Use full path to python executable and validated module name
|
|
179
|
+
python_executable = (
|
|
180
|
+
sys.executable or shutil.which("python3") or shutil.which("python")
|
|
181
|
+
)
|
|
182
|
+
if not python_executable:
|
|
183
|
+
return Result(
|
|
184
|
+
status=OperationStatus.FAILED,
|
|
185
|
+
error="Could not find Python executable",
|
|
186
|
+
error_code="PYTHON_NOT_FOUND",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Only capture logs if background=True AND show_logs=False
|
|
190
|
+
capture_logs = background and not show_logs
|
|
191
|
+
process = subprocess.Popen(
|
|
192
|
+
[python_executable, "-m", script_module], # nosec B603 - using validated module and full path
|
|
193
|
+
env=env,
|
|
194
|
+
stdout=subprocess.PIPE if capture_logs else None,
|
|
195
|
+
stderr=subprocess.PIPE if capture_logs else None,
|
|
196
|
+
text=True,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
server_process = ServerProcess(
|
|
200
|
+
process=process,
|
|
201
|
+
process_id=process.pid,
|
|
202
|
+
is_background=background,
|
|
203
|
+
config=server_config,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
self._running_servers[server_config.name] = server_process
|
|
207
|
+
|
|
208
|
+
# Create ServerInfo
|
|
209
|
+
server_info = ServerInfo(
|
|
210
|
+
name=server_config.name,
|
|
211
|
+
server_type=server_type,
|
|
212
|
+
state=ServerState.RUNNING,
|
|
213
|
+
host=server_config.host,
|
|
214
|
+
port=server_config.port,
|
|
215
|
+
transport=server_config.transport,
|
|
216
|
+
url=f"http://{server_config.host}:{server_config.port}{server_config.path}",
|
|
217
|
+
pid=process.pid,
|
|
218
|
+
start_time=server_process.start_time,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return Result(status=OperationStatus.SUCCESS, data=server_info)
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
return Result(
|
|
225
|
+
status=OperationStatus.FAILED,
|
|
226
|
+
error=f"Failed to start server subprocess: {e}",
|
|
227
|
+
error_code="SUBPROCESS_START_FAILED",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
async def _start_server_traditional(
|
|
231
|
+
self, server_config: ServerConfig, background: bool
|
|
232
|
+
) -> Result[ServerInfo]:
|
|
233
|
+
"""Start a server using the traditional approach (for stdio transport)."""
|
|
234
|
+
try:
|
|
235
|
+
# Create server instance
|
|
236
|
+
server_type = server_config.config.get("type", "unknown")
|
|
237
|
+
if not server_type or server_type == "unknown":
|
|
238
|
+
return Result(
|
|
239
|
+
status=OperationStatus.FAILED,
|
|
240
|
+
error="Server type not specified in configuration",
|
|
241
|
+
error_code="MISSING_SERVER_TYPE",
|
|
242
|
+
)
|
|
243
|
+
server = self.registry.create_server(server_type, server_config)
|
|
244
|
+
|
|
245
|
+
if background:
|
|
246
|
+
# Run in background thread with proper asyncio handling
|
|
247
|
+
import threading
|
|
248
|
+
|
|
249
|
+
thread = threading.Thread(
|
|
250
|
+
target=self._run_server_in_thread,
|
|
251
|
+
args=(server, server_config.name),
|
|
252
|
+
daemon=True,
|
|
253
|
+
)
|
|
254
|
+
thread.start()
|
|
255
|
+
|
|
256
|
+
server_process = ServerProcess(
|
|
257
|
+
server=server,
|
|
258
|
+
thread=thread,
|
|
259
|
+
is_background=True,
|
|
260
|
+
config=server_config,
|
|
261
|
+
)
|
|
262
|
+
else:
|
|
263
|
+
# Run in foreground (blocking)
|
|
264
|
+
server_process = ServerProcess(
|
|
265
|
+
server=server, is_background=False, config=server_config
|
|
266
|
+
)
|
|
267
|
+
# This will block
|
|
268
|
+
server.run()
|
|
269
|
+
|
|
270
|
+
self._running_servers[server_config.name] = server_process
|
|
271
|
+
|
|
272
|
+
# Create ServerInfo
|
|
273
|
+
server_info = ServerInfo(
|
|
274
|
+
name=server_config.name,
|
|
275
|
+
server_type=server_config.config.get("type", "unknown"),
|
|
276
|
+
state=ServerState.RUNNING,
|
|
277
|
+
host=server_config.host,
|
|
278
|
+
port=server_config.port,
|
|
279
|
+
transport=server_config.transport,
|
|
280
|
+
start_time=server_process.start_time,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return Result(status=OperationStatus.SUCCESS, data=server_info)
|
|
284
|
+
|
|
285
|
+
except Exception as e:
|
|
286
|
+
return Result(
|
|
287
|
+
status=OperationStatus.FAILED,
|
|
288
|
+
error=f"Failed to start server traditionally: {e}",
|
|
289
|
+
error_code="TRADITIONAL_START_FAILED",
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def _run_server_in_thread(self, server: BaseServer, server_name: str):
|
|
293
|
+
"""Run a server in a background thread with proper asyncio handling."""
|
|
294
|
+
try:
|
|
295
|
+
logger.info(f"Running server {server_name} in background thread")
|
|
296
|
+
|
|
297
|
+
# Create a new event loop for this thread
|
|
298
|
+
# This is crucial for FastMCP servers which are asyncio-based
|
|
299
|
+
import asyncio
|
|
300
|
+
|
|
301
|
+
loop = asyncio.new_event_loop()
|
|
302
|
+
asyncio.set_event_loop(loop)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
# Run the server in the new event loop
|
|
306
|
+
server.run()
|
|
307
|
+
finally:
|
|
308
|
+
# Clean up the event loop
|
|
309
|
+
loop.close()
|
|
310
|
+
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.error(f"Error running server {server_name}", error=e)
|
|
313
|
+
finally:
|
|
314
|
+
# Clean up the server from running list when it stops
|
|
315
|
+
if server_name in self._running_servers:
|
|
316
|
+
logger.info(f"Server {server_name} stopped")
|
|
317
|
+
del self._running_servers[server_name]
|
|
318
|
+
|
|
319
|
+
@with_correlation_id
|
|
320
|
+
async def start_multiple_servers(
|
|
321
|
+
self,
|
|
322
|
+
server_configs: List[ServerConfig],
|
|
323
|
+
background: bool = True,
|
|
324
|
+
show_logs: bool = True,
|
|
325
|
+
) -> Result[Dict[str, bool]]:
|
|
326
|
+
"""Start multiple servers concurrently."""
|
|
327
|
+
if not server_configs:
|
|
328
|
+
return Result(status=OperationStatus.SUCCESS, data={})
|
|
329
|
+
|
|
330
|
+
logger.info(f"Starting {len(server_configs)} servers concurrently")
|
|
331
|
+
|
|
332
|
+
# Create startup operations
|
|
333
|
+
async def start_single_server(config: ServerConfig) -> bool:
|
|
334
|
+
result = await self.start_server(config, background, show_logs)
|
|
335
|
+
return result.is_success
|
|
336
|
+
|
|
337
|
+
operations = [
|
|
338
|
+
lambda cfg=config: start_single_server(cfg) for config in server_configs
|
|
339
|
+
]
|
|
340
|
+
operation_names = [f"start_{config.name}" for config in server_configs]
|
|
341
|
+
|
|
342
|
+
# Execute with controlled concurrency
|
|
343
|
+
results = await run_concurrent_operations(
|
|
344
|
+
operations,
|
|
345
|
+
max_concurrent=self.max_concurrent_startups,
|
|
346
|
+
operation_names=operation_names,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Build result dictionary
|
|
350
|
+
startup_results = {}
|
|
351
|
+
for config, result in zip(server_configs, results):
|
|
352
|
+
# If the operation succeeded, use the data (boolean result)
|
|
353
|
+
# If the operation failed (exception), treat as False
|
|
354
|
+
startup_results[config.name] = result.data if result.is_success else False
|
|
355
|
+
|
|
356
|
+
successful = sum(1 for success in startup_results.values() if success)
|
|
357
|
+
logger.info(
|
|
358
|
+
f"Server startup completed: {successful}/{len(server_configs)} successful"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
return Result(status=OperationStatus.SUCCESS, data=startup_results)
|
|
362
|
+
|
|
363
|
+
def get_running_servers(self) -> Dict[str, ServerInfo]:
|
|
364
|
+
"""Get information about all running servers."""
|
|
365
|
+
info = {}
|
|
366
|
+
for name, server_process in self._running_servers.items():
|
|
367
|
+
if server_process.server:
|
|
368
|
+
info[name] = server_process.server.info
|
|
369
|
+
elif server_process.config:
|
|
370
|
+
# Create ServerInfo for subprocess-based servers
|
|
371
|
+
server_info = ServerInfo(
|
|
372
|
+
name=server_process.config.name,
|
|
373
|
+
server_type=server_process.config.config.get("type", "unknown"),
|
|
374
|
+
state=ServerState.RUNNING
|
|
375
|
+
if self._is_process_running(server_process)
|
|
376
|
+
else ServerState.ERROR,
|
|
377
|
+
host=server_process.config.host,
|
|
378
|
+
port=server_process.config.port,
|
|
379
|
+
transport=server_process.config.transport,
|
|
380
|
+
start_time=server_process.start_time,
|
|
381
|
+
pid=server_process.process_id,
|
|
382
|
+
)
|
|
383
|
+
if server_process.config.transport in ["http", "streamable-http"]:
|
|
384
|
+
server_info.url = (
|
|
385
|
+
f"http://{server_process.config.host}:{server_process.config.port}"
|
|
386
|
+
f"{server_process.config.path}"
|
|
387
|
+
)
|
|
388
|
+
info[name] = server_info
|
|
389
|
+
return info
|
|
390
|
+
|
|
391
|
+
def _is_process_running(self, server_process: ServerProcess) -> bool:
|
|
392
|
+
"""Check if a subprocess is still running."""
|
|
393
|
+
if server_process.process:
|
|
394
|
+
return server_process.process.poll() is None
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
def shutdown_all(self):
|
|
398
|
+
"""Shutdown all running servers."""
|
|
399
|
+
logger.info("Shutting down all servers...")
|
|
400
|
+
|
|
401
|
+
server_names = list(self._running_servers.keys())
|
|
402
|
+
for name in server_names:
|
|
403
|
+
self.stop_server(name)
|
|
404
|
+
|
|
405
|
+
self._shutdown_event.set()
|
|
406
|
+
logger.info("All servers shut down")
|
|
407
|
+
|
|
408
|
+
def stop_server(self, server_name: str) -> bool:
|
|
409
|
+
"""Stop a specific server."""
|
|
410
|
+
if server_name not in self._running_servers:
|
|
411
|
+
logger.warning(f"Server {server_name} is not running")
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
server_process = self._running_servers[server_name]
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
# Handle subprocess
|
|
418
|
+
if server_process.process:
|
|
419
|
+
try:
|
|
420
|
+
server_process.process.terminate()
|
|
421
|
+
# Wait up to 5 seconds for graceful shutdown
|
|
422
|
+
server_process.process.wait(timeout=5)
|
|
423
|
+
except subprocess.TimeoutExpired:
|
|
424
|
+
logger.warning(
|
|
425
|
+
f"Server {server_name} didn't stop gracefully, forcing..."
|
|
426
|
+
)
|
|
427
|
+
server_process.process.kill()
|
|
428
|
+
except Exception as e:
|
|
429
|
+
logger.error(
|
|
430
|
+
f"Error stopping server process {server_name}", error=e
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Handle threaded server
|
|
434
|
+
elif server_process.thread and server_process.thread.is_alive():
|
|
435
|
+
logger.info(
|
|
436
|
+
f"Server {server_name} thread is still running, waiting for natural shutdown..."
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# Remove from running servers
|
|
440
|
+
del self._running_servers[server_name]
|
|
441
|
+
logger.info(f"Stopped server: {server_name}")
|
|
442
|
+
return True
|
|
443
|
+
|
|
444
|
+
except Exception as e:
|
|
445
|
+
logger.error(f"Error stopping server {server_name}", error=e)
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# Global orchestrator instance
|
|
450
|
+
_orchestrator: Optional[ServerOrchestrator] = None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def get_orchestrator() -> ServerOrchestrator:
|
|
454
|
+
"""Get the global server orchestrator instance."""
|
|
455
|
+
global _orchestrator
|
|
456
|
+
if _orchestrator is None:
|
|
457
|
+
_orchestrator = ServerOrchestrator()
|
|
458
|
+
return _orchestrator
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def reset_orchestrator():
|
|
462
|
+
"""Reset the global orchestrator (mainly for testing)."""
|
|
463
|
+
global _orchestrator
|
|
464
|
+
if _orchestrator is not None:
|
|
465
|
+
_orchestrator.shutdown_all()
|
|
466
|
+
_orchestrator = None
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Server registry for discovering and managing MCP servers."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import pkgutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from lightfast_mcp.core.base_server import BaseServer, ServerConfig
|
|
10
|
+
from lightfast_mcp.utils.logging_utils import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger("ServerRegistry")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ServerRegistry:
|
|
16
|
+
"""Registry for discovering and managing MCP servers."""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
"""Initialize the server registry."""
|
|
20
|
+
self._server_classes: dict[str, type[BaseServer]] = {}
|
|
21
|
+
self._server_instances: dict[str, BaseServer] = {}
|
|
22
|
+
|
|
23
|
+
# Auto-discover servers on initialization
|
|
24
|
+
self.discover_servers()
|
|
25
|
+
|
|
26
|
+
def discover_servers(self):
|
|
27
|
+
"""Automatically discover all available server classes."""
|
|
28
|
+
logger.info("Discovering MCP servers...")
|
|
29
|
+
|
|
30
|
+
# Discover servers in the servers package
|
|
31
|
+
self._discover_servers_in_package("lightfast_mcp.servers")
|
|
32
|
+
|
|
33
|
+
logger.info(
|
|
34
|
+
f"Found {len(self._server_classes)} server types: {list(self._server_classes.keys())}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _discover_servers_in_package(self, package_name: str):
|
|
38
|
+
"""Discover servers in a specific package."""
|
|
39
|
+
try:
|
|
40
|
+
# Import the package
|
|
41
|
+
package = importlib.import_module(package_name)
|
|
42
|
+
|
|
43
|
+
# Check if package has a file path
|
|
44
|
+
if package.__file__ is None:
|
|
45
|
+
logger.debug(f"Package {package_name} has no __file__ attribute")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
package_path = Path(package.__file__).parent
|
|
49
|
+
|
|
50
|
+
# Iterate through all modules in the package
|
|
51
|
+
for _finder, name, ispkg in pkgutil.iter_modules([str(package_path)]):
|
|
52
|
+
if ispkg:
|
|
53
|
+
# Recursively search subpackages
|
|
54
|
+
subpackage_name = f"{package_name}.{name}"
|
|
55
|
+
self._discover_servers_in_package(subpackage_name)
|
|
56
|
+
else:
|
|
57
|
+
# Import the module and look for server classes
|
|
58
|
+
try:
|
|
59
|
+
module_name = f"{package_name}.{name}"
|
|
60
|
+
module = importlib.import_module(module_name)
|
|
61
|
+
self._discover_servers_in_module(module)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
logger.debug(f"Could not import module {module_name}: {e}")
|
|
64
|
+
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.debug(f"Could not discover servers in package {package_name}: {e}")
|
|
67
|
+
|
|
68
|
+
def _discover_servers_in_module(self, module):
|
|
69
|
+
"""Discover server classes in a module."""
|
|
70
|
+
for name, obj in inspect.getmembers(module):
|
|
71
|
+
if (
|
|
72
|
+
inspect.isclass(obj)
|
|
73
|
+
and issubclass(obj, BaseServer)
|
|
74
|
+
and obj is not BaseServer
|
|
75
|
+
and hasattr(obj, "SERVER_TYPE")
|
|
76
|
+
):
|
|
77
|
+
server_type = obj.SERVER_TYPE
|
|
78
|
+
if server_type != "base": # Skip the base class
|
|
79
|
+
logger.debug(f"Found server class: {name} (type: {server_type})")
|
|
80
|
+
self._server_classes[server_type] = obj
|
|
81
|
+
|
|
82
|
+
def register_server_class(self, server_type: str, server_class: type[BaseServer]):
|
|
83
|
+
"""Manually register a server class."""
|
|
84
|
+
logger.info(f"Registering server class: {server_type}")
|
|
85
|
+
self._server_classes[server_type] = server_class
|
|
86
|
+
|
|
87
|
+
def get_server_class(self, server_type: str) -> type[BaseServer] | None:
|
|
88
|
+
"""Get a server class by type."""
|
|
89
|
+
return self._server_classes.get(server_type)
|
|
90
|
+
|
|
91
|
+
def get_available_server_types(self) -> list[str]:
|
|
92
|
+
"""Get list of all available server types."""
|
|
93
|
+
return list(self._server_classes.keys())
|
|
94
|
+
|
|
95
|
+
def create_server(self, server_type: str, config: ServerConfig) -> BaseServer:
|
|
96
|
+
"""Create a server instance from configuration."""
|
|
97
|
+
server_class = self.get_server_class(server_type)
|
|
98
|
+
if not server_class:
|
|
99
|
+
raise ValueError(f"Unknown server type: {server_type}")
|
|
100
|
+
|
|
101
|
+
logger.info(f"Creating server instance: {config.name} (type: {server_type})")
|
|
102
|
+
server = server_class.create_from_config(config)
|
|
103
|
+
|
|
104
|
+
# Store the instance
|
|
105
|
+
self._server_instances[config.name] = server
|
|
106
|
+
return server
|
|
107
|
+
|
|
108
|
+
def get_server_instance(self, name: str) -> BaseServer | None:
|
|
109
|
+
"""Get a running server instance by name."""
|
|
110
|
+
return self._server_instances.get(name)
|
|
111
|
+
|
|
112
|
+
def get_all_server_instances(self) -> dict[str, BaseServer]:
|
|
113
|
+
"""Get all server instances."""
|
|
114
|
+
return self._server_instances.copy()
|
|
115
|
+
|
|
116
|
+
def remove_server_instance(self, name: str) -> bool:
|
|
117
|
+
"""Remove a server instance."""
|
|
118
|
+
if name in self._server_instances:
|
|
119
|
+
logger.info(f"Removing server instance: {name}")
|
|
120
|
+
del self._server_instances[name]
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
def get_server_info(self) -> dict[str, dict[str, Any]]:
|
|
125
|
+
"""Get information about all available server types."""
|
|
126
|
+
info = {}
|
|
127
|
+
for server_type, server_class in self._server_classes.items():
|
|
128
|
+
info[server_type] = {
|
|
129
|
+
"class_name": server_class.__name__,
|
|
130
|
+
"version": getattr(server_class, "SERVER_VERSION", "1.0.0"),
|
|
131
|
+
"required_dependencies": getattr(
|
|
132
|
+
server_class, "REQUIRED_DEPENDENCIES", []
|
|
133
|
+
),
|
|
134
|
+
"required_apps": getattr(server_class, "REQUIRED_APPS", []),
|
|
135
|
+
"description": getattr(
|
|
136
|
+
server_class, "__doc__", "No description available"
|
|
137
|
+
),
|
|
138
|
+
}
|
|
139
|
+
return info
|
|
140
|
+
|
|
141
|
+
def validate_server_config(
|
|
142
|
+
self, server_type: str, config: ServerConfig
|
|
143
|
+
) -> tuple[bool, str]:
|
|
144
|
+
"""Validate a server configuration."""
|
|
145
|
+
server_class = self.get_server_class(server_type)
|
|
146
|
+
if not server_class:
|
|
147
|
+
return False, f"Unknown server type: {server_type}"
|
|
148
|
+
|
|
149
|
+
# Basic validation
|
|
150
|
+
if not config.name:
|
|
151
|
+
return False, "Server name is required"
|
|
152
|
+
|
|
153
|
+
if not config.description:
|
|
154
|
+
return False, "Server description is required"
|
|
155
|
+
|
|
156
|
+
# Check for port conflicts with existing instances
|
|
157
|
+
for instance in self._server_instances.values():
|
|
158
|
+
if (
|
|
159
|
+
instance.config.port == config.port
|
|
160
|
+
and instance.config.host == config.host
|
|
161
|
+
and instance.config.transport in ["http", "streamable-http"]
|
|
162
|
+
and config.transport in ["http", "streamable-http"]
|
|
163
|
+
):
|
|
164
|
+
return (
|
|
165
|
+
False,
|
|
166
|
+
f"Port {config.port} on {config.host} is already in use by {instance.config.name}",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return True, "Configuration is valid"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Global registry instance
|
|
173
|
+
_registry: ServerRegistry | None = None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_registry() -> ServerRegistry:
|
|
177
|
+
"""Get the global server registry instance."""
|
|
178
|
+
global _registry
|
|
179
|
+
if _registry is None:
|
|
180
|
+
_registry = ServerRegistry()
|
|
181
|
+
return _registry
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def reset_registry():
|
|
185
|
+
"""Reset the global registry (mainly for testing)."""
|
|
186
|
+
global _registry
|
|
187
|
+
_registry = None
|