open-edison 0.1.16__py3-none-any.whl → 0.1.19__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.
- {open_edison-0.1.16.dist-info → open_edison-0.1.19.dist-info}/METADATA +92 -22
- open_edison-0.1.19.dist-info/RECORD +14 -0
- src/middleware/data_access_tracker.py +31 -2
- src/middleware/session_tracking.py +1 -1
- src/server.py +115 -144
- src/single_user_mcp.py +95 -112
- src/telemetry.py +17 -1
- open_edison-0.1.16.dist-info/RECORD +0 -18
- src/frontend_dist/assets/index-_NTxjOfh.js +0 -51
- src/frontend_dist/assets/index-h6k8aL6h.css +0 -1
- src/frontend_dist/index.html +0 -21
- src/mcp_manager.py +0 -137
- {open_edison-0.1.16.dist-info → open_edison-0.1.19.dist-info}/WHEEL +0 -0
- {open_edison-0.1.16.dist-info → open_edison-0.1.19.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.16.dist-info → open_edison-0.1.19.dist-info}/licenses/LICENSE +0 -0
src/server.py
CHANGED
@@ -6,6 +6,7 @@ No multi-user support, no complex routing - just a straightforward proxy.
|
|
6
6
|
"""
|
7
7
|
|
8
8
|
import asyncio
|
9
|
+
import json
|
9
10
|
import traceback
|
10
11
|
from collections.abc import Awaitable, Callable, Coroutine
|
11
12
|
from pathlib import Path
|
@@ -23,7 +24,6 @@ from pydantic import BaseModel, Field
|
|
23
24
|
|
24
25
|
from src.config import MCPServerConfig, config
|
25
26
|
from src.config import get_config_dir as _get_cfg_dir # type: ignore[attr-defined]
|
26
|
-
from src.mcp_manager import MCPManager
|
27
27
|
from src.middleware.session_tracking import (
|
28
28
|
MCPSessionModel,
|
29
29
|
create_db_session,
|
@@ -57,8 +57,7 @@ class OpenEdisonProxy:
|
|
57
57
|
self.port: int = port
|
58
58
|
|
59
59
|
# Initialize components
|
60
|
-
self.
|
61
|
-
self.single_user_mcp: SingleUserMCP = SingleUserMCP(self.mcp_manager)
|
60
|
+
self.single_user_mcp: SingleUserMCP = SingleUserMCP()
|
62
61
|
|
63
62
|
# Initialize FastAPI app for management
|
64
63
|
self.fastapi_app: FastAPI = self._create_fastapi_app()
|
@@ -184,30 +183,33 @@ class OpenEdisonProxy:
|
|
184
183
|
"""
|
185
184
|
Resolve a JSON config file path consistently with src.config defaults.
|
186
185
|
|
187
|
-
Precedence for reads
|
188
|
-
1)
|
189
|
-
2)
|
190
|
-
|
191
|
-
3) Current working directory as last resort
|
186
|
+
Precedence for reads and writes:
|
187
|
+
1) Config dir (OPEN_EDISON_CONFIG_DIR or platform default) — if file exists
|
188
|
+
2) Repository/package defaults next to src/ — and bootstrap a copy into the config dir if missing
|
189
|
+
3) Config dir target path (even if not yet created) as last resort
|
192
190
|
"""
|
193
|
-
# 1)
|
194
|
-
repo_candidate = Path(__file__).parent.parent / filename
|
195
|
-
if repo_candidate.exists():
|
196
|
-
return repo_candidate
|
197
|
-
|
198
|
-
# 2) Config directory
|
191
|
+
# 1) Config directory (preferred)
|
199
192
|
try:
|
200
193
|
base = _get_cfg_dir()
|
201
194
|
except Exception:
|
202
195
|
base = Path.cwd()
|
203
196
|
target = base / filename
|
204
|
-
if
|
197
|
+
if target.exists():
|
198
|
+
return target
|
199
|
+
|
200
|
+
# 2) Repository/package defaults next to src/
|
201
|
+
repo_candidate = Path(__file__).parent.parent / filename
|
202
|
+
if repo_candidate.exists():
|
203
|
+
# Bootstrap a copy into config dir when possible
|
205
204
|
try:
|
206
205
|
target.parent.mkdir(parents=True, exist_ok=True)
|
207
206
|
target.write_text(repo_candidate.read_text(encoding="utf-8"), encoding="utf-8")
|
208
207
|
except Exception:
|
209
208
|
pass
|
210
|
-
|
209
|
+
return target if target.exists() else repo_candidate
|
210
|
+
|
211
|
+
# 3) Fall back to config dir path (will be created on save)
|
212
|
+
return target
|
211
213
|
|
212
214
|
async def _serve_json(filename: str) -> Response: # type: ignore[override]
|
213
215
|
if filename not in allowed_json_files:
|
@@ -238,23 +240,28 @@ class OpenEdisonProxy:
|
|
238
240
|
content = body.get("content", "")
|
239
241
|
if not isinstance(content, str):
|
240
242
|
raise ValueError("content must be string")
|
243
|
+
source: str = "unknown"
|
241
244
|
if isinstance(name, str) and name in allowed_json_files:
|
242
245
|
target = _resolve_json_path(name)
|
246
|
+
source = f"name={name}"
|
243
247
|
elif isinstance(path_val, str):
|
244
|
-
|
245
|
-
# Normalize path but restrict to allowed filenames
|
248
|
+
# Normalize path but restrict to allowed filenames, then resolve like reads
|
246
249
|
candidate = Path(path_val)
|
247
250
|
filename = candidate.name
|
248
251
|
if filename not in allowed_json_files:
|
249
252
|
raise ValueError("filename not allowed")
|
250
|
-
target =
|
253
|
+
target = _resolve_json_path(filename)
|
254
|
+
source = f"path={path_val} -> filename={filename}"
|
251
255
|
else:
|
252
256
|
raise ValueError("invalid target file")
|
253
|
-
# Basic validation to ensure valid JSON
|
254
|
-
import json as _json
|
255
257
|
|
256
|
-
|
258
|
+
log.debug(
|
259
|
+
f"Saving JSON config ({source}), resolved target: {target} (bytes={len(content.encode('utf-8'))})"
|
260
|
+
)
|
261
|
+
|
262
|
+
_ = json.loads(content or "{}")
|
257
263
|
target.write_text(content or "{}", encoding="utf-8")
|
264
|
+
log.debug(f"Saved JSON config to {target}")
|
258
265
|
return {"status": "ok"}
|
259
266
|
except Exception as e: # noqa: BLE001
|
260
267
|
raise HTTPException(status_code=400, detail=f"Save failed: {e}") from e
|
@@ -348,12 +355,6 @@ class OpenEdisonProxy:
|
|
348
355
|
log.info("🚀 Starting both FastAPI and FastMCP servers...")
|
349
356
|
_ = await asyncio.gather(*servers_to_run)
|
350
357
|
|
351
|
-
async def shutdown(self) -> None:
|
352
|
-
"""Shutdown the proxy server and all MCP servers"""
|
353
|
-
log.info("🛑 Shutting down Open Edison proxy server")
|
354
|
-
await self.mcp_manager.shutdown()
|
355
|
-
log.info("✅ Open Edison proxy server shutdown complete")
|
356
|
-
|
357
358
|
def _register_routes(self, app: FastAPI) -> None:
|
358
359
|
"""Register all routes for the FastAPI app"""
|
359
360
|
# Register routes with their decorators
|
@@ -362,13 +363,6 @@ class OpenEdisonProxy:
|
|
362
363
|
"/mcp/status",
|
363
364
|
self.mcp_status,
|
364
365
|
methods=["GET"],
|
365
|
-
dependencies=[Depends(self.verify_api_key)],
|
366
|
-
)
|
367
|
-
app.add_api_route(
|
368
|
-
"/mcp/{server_name}/start",
|
369
|
-
self.start_mcp_server,
|
370
|
-
methods=["POST"],
|
371
|
-
dependencies=[Depends(self.verify_api_key)],
|
372
366
|
)
|
373
367
|
app.add_api_route(
|
374
368
|
"/mcp/validate",
|
@@ -376,18 +370,6 @@ class OpenEdisonProxy:
|
|
376
370
|
methods=["POST"],
|
377
371
|
# Intentionally no auth required for validation for now
|
378
372
|
)
|
379
|
-
app.add_api_route(
|
380
|
-
"/mcp/{server_name}/stop",
|
381
|
-
self.stop_mcp_server,
|
382
|
-
methods=["POST"],
|
383
|
-
dependencies=[Depends(self.verify_api_key)],
|
384
|
-
)
|
385
|
-
app.add_api_route(
|
386
|
-
"/mcp/call",
|
387
|
-
self.proxy_mcp_call,
|
388
|
-
methods=["POST"],
|
389
|
-
dependencies=[Depends(self.verify_api_key)],
|
390
|
-
)
|
391
373
|
app.add_api_route(
|
392
374
|
"/mcp/mounted",
|
393
375
|
self.get_mounted_servers,
|
@@ -395,14 +377,8 @@ class OpenEdisonProxy:
|
|
395
377
|
dependencies=[Depends(self.verify_api_key)],
|
396
378
|
)
|
397
379
|
app.add_api_route(
|
398
|
-
"/mcp/
|
399
|
-
self.
|
400
|
-
methods=["POST"],
|
401
|
-
dependencies=[Depends(self.verify_api_key)],
|
402
|
-
)
|
403
|
-
app.add_api_route(
|
404
|
-
"/mcp/{server_name}/unmount",
|
405
|
-
self.unmount_server,
|
380
|
+
"/mcp/reinitialize",
|
381
|
+
self.reinitialize_mcp_servers,
|
406
382
|
methods=["POST"],
|
407
383
|
dependencies=[Depends(self.verify_api_key)],
|
408
384
|
)
|
@@ -412,6 +388,12 @@ class OpenEdisonProxy:
|
|
412
388
|
self.get_sessions,
|
413
389
|
methods=["GET"],
|
414
390
|
)
|
391
|
+
# Cache invalidation endpoint (no auth required - allowed to fail)
|
392
|
+
app.add_api_route(
|
393
|
+
"/api/clear-caches",
|
394
|
+
self.clear_caches,
|
395
|
+
methods=["POST"],
|
396
|
+
)
|
415
397
|
|
416
398
|
async def verify_api_key(
|
417
399
|
self, credentials: HTTPAuthorizationCredentials = _auth_dependency
|
@@ -426,6 +408,18 @@ class OpenEdisonProxy:
|
|
426
408
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
427
409
|
return credentials.credentials
|
428
410
|
|
411
|
+
async def mcp_status(self) -> dict[str, list[dict[str, Any]]]:
|
412
|
+
"""Get status of configured MCP servers (auth required)."""
|
413
|
+
return {
|
414
|
+
"servers": [
|
415
|
+
{
|
416
|
+
"name": server.name,
|
417
|
+
"enabled": server.enabled,
|
418
|
+
}
|
419
|
+
for server in config.mcp_servers
|
420
|
+
]
|
421
|
+
}
|
422
|
+
|
429
423
|
def _handle_server_operation_error(
|
430
424
|
self, operation: str, server_name: str, error: Exception
|
431
425
|
) -> HTTPException:
|
@@ -451,63 +445,6 @@ class OpenEdisonProxy:
|
|
451
445
|
"""Health check endpoint"""
|
452
446
|
return {"status": "healthy", "version": "0.1.0", "mcp_servers": len(config.mcp_servers)}
|
453
447
|
|
454
|
-
async def mcp_status(self) -> dict[str, list[dict[str, str | bool]]]:
|
455
|
-
"""Get status of configured MCP servers"""
|
456
|
-
return {
|
457
|
-
"servers": [
|
458
|
-
{
|
459
|
-
"name": server.name,
|
460
|
-
"enabled": server.enabled,
|
461
|
-
"running": await self.mcp_manager.is_server_running(server.name),
|
462
|
-
}
|
463
|
-
for server in config.mcp_servers
|
464
|
-
]
|
465
|
-
}
|
466
|
-
|
467
|
-
async def start_mcp_server(self, server_name: str) -> dict[str, str]:
|
468
|
-
"""Start a specific MCP server"""
|
469
|
-
try:
|
470
|
-
_ = await self.mcp_manager.start_server(server_name)
|
471
|
-
return {"message": f"Server {server_name} started successfully"}
|
472
|
-
except Exception as e:
|
473
|
-
raise self._handle_server_operation_error("start", server_name, e) from e
|
474
|
-
|
475
|
-
async def stop_mcp_server(self, server_name: str) -> dict[str, str]:
|
476
|
-
"""Stop a specific MCP server"""
|
477
|
-
try:
|
478
|
-
await self.mcp_manager.stop_server(server_name)
|
479
|
-
return {"message": f"Server {server_name} stopped successfully"}
|
480
|
-
except Exception as e:
|
481
|
-
raise self._handle_server_operation_error("stop", server_name, e) from e
|
482
|
-
|
483
|
-
async def proxy_mcp_call(self, request: dict[str, Any]) -> dict[str, Any]:
|
484
|
-
"""
|
485
|
-
Proxy MCP calls to mounted servers.
|
486
|
-
|
487
|
-
This now routes requests through the mounted FastMCP servers.
|
488
|
-
"""
|
489
|
-
try:
|
490
|
-
log.info(f"Proxying MCP request: {request.get('method', 'unknown')}")
|
491
|
-
|
492
|
-
mounted = await self.single_user_mcp.get_mounted_servers()
|
493
|
-
mounted_names = [server["name"] for server in mounted]
|
494
|
-
|
495
|
-
return {
|
496
|
-
"jsonrpc": "2.0",
|
497
|
-
"id": request.get("id"),
|
498
|
-
"result": {
|
499
|
-
"message": "MCP request routed through FastMCP",
|
500
|
-
"request": request,
|
501
|
-
"mounted_servers": mounted_names,
|
502
|
-
},
|
503
|
-
}
|
504
|
-
except Exception as e:
|
505
|
-
log.error(f"Failed to proxy MCP call: {e}")
|
506
|
-
raise HTTPException(
|
507
|
-
status_code=500,
|
508
|
-
detail=f"Failed to proxy MCP call: {str(e)}",
|
509
|
-
) from e
|
510
|
-
|
511
448
|
async def get_mounted_servers(self) -> dict[str, Any]:
|
512
449
|
"""Get list of currently mounted MCP servers."""
|
513
450
|
try:
|
@@ -520,35 +457,48 @@ class OpenEdisonProxy:
|
|
520
457
|
detail=f"Failed to get mounted servers: {str(e)}",
|
521
458
|
) from e
|
522
459
|
|
523
|
-
async def
|
524
|
-
"""
|
460
|
+
async def reinitialize_mcp_servers(self) -> dict[str, Any]:
|
461
|
+
"""Reinitialize all MCP servers by creating a fresh instance and reloading config."""
|
462
|
+
old_mcp = None
|
525
463
|
try:
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
|
532
|
-
|
533
|
-
)
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
464
|
+
log.info("🔄 Reinitializing MCP servers via API endpoint")
|
465
|
+
|
466
|
+
# Reload configuration from disk
|
467
|
+
log.info("Reloading configuration from disk")
|
468
|
+
from src.config import Config
|
469
|
+
|
470
|
+
fresh_config = Config.load()
|
471
|
+
log.info("✅ Configuration reloaded from disk")
|
472
|
+
|
473
|
+
# Create a completely new SingleUserMCP instance to ensure clean state
|
474
|
+
old_mcp = self.single_user_mcp
|
475
|
+
self.single_user_mcp = SingleUserMCP()
|
476
|
+
|
477
|
+
# Initialize the new instance with fresh config
|
478
|
+
await self.single_user_mcp.initialize(fresh_config)
|
479
|
+
|
480
|
+
# Get final status
|
481
|
+
final_mounted = await self.single_user_mcp.get_mounted_servers()
|
482
|
+
|
483
|
+
result = {
|
484
|
+
"status": "success",
|
485
|
+
"message": "MCP servers reinitialized successfully",
|
486
|
+
"final_mounted_servers": [server["name"] for server in final_mounted],
|
487
|
+
"total_final_mounted": len(final_mounted),
|
488
|
+
}
|
489
|
+
|
490
|
+
log.info("✅ MCP servers reinitialized successfully via API")
|
491
|
+
return result
|
538
492
|
|
539
|
-
async def unmount_server(self, server_name: str) -> dict[str, str]:
|
540
|
-
"""Unmount a specific MCP server."""
|
541
|
-
try:
|
542
|
-
if server_name == "test-echo":
|
543
|
-
log.info("Special handling for test-echo server unmount")
|
544
|
-
_ = await self.single_user_mcp.unmount_server(server_name)
|
545
|
-
return {"message": f"Server {server_name} unmounted successfully"}
|
546
|
-
_ = await self.single_user_mcp.unmount_server(server_name)
|
547
|
-
return {"message": f"Server {server_name} unmounted successfully"}
|
548
|
-
except HTTPException:
|
549
|
-
raise
|
550
493
|
except Exception as e:
|
551
|
-
|
494
|
+
log.error(f"❌ Failed to reinitialize MCP servers: {e}")
|
495
|
+
# Restore the old instance on failure
|
496
|
+
if old_mcp is not None:
|
497
|
+
self.single_user_mcp = old_mcp
|
498
|
+
raise HTTPException(
|
499
|
+
status_code=500,
|
500
|
+
detail=f"Failed to reinitialize MCP servers: {str(e)}",
|
501
|
+
) from e
|
552
502
|
|
553
503
|
async def get_sessions(self) -> dict[str, Any]:
|
554
504
|
"""Return recent MCP session summaries from local SQLite.
|
@@ -599,6 +549,21 @@ class OpenEdisonProxy:
|
|
599
549
|
log.error(f"Failed to fetch sessions: {e}")
|
600
550
|
raise HTTPException(status_code=500, detail="Failed to fetch sessions") from e
|
601
551
|
|
552
|
+
async def clear_caches(self) -> dict[str, str]:
|
553
|
+
"""Clear all permission caches to force reload from configuration files."""
|
554
|
+
try:
|
555
|
+
from src.middleware.data_access_tracker import clear_all_permissions_caches
|
556
|
+
|
557
|
+
log.info("🔄 Clearing all permission caches via API endpoint")
|
558
|
+
clear_all_permissions_caches()
|
559
|
+
log.info("✅ All permission caches cleared successfully")
|
560
|
+
|
561
|
+
return {"status": "success", "message": "All permission caches cleared"}
|
562
|
+
except Exception as e:
|
563
|
+
log.error(f"❌ Failed to clear permission caches: {e}")
|
564
|
+
# Don't raise HTTPException - allow to fail gracefully as requested
|
565
|
+
return {"status": "error", "message": f"Failed to clear caches: {str(e)}"}
|
566
|
+
|
602
567
|
# ---- MCP validation ----
|
603
568
|
class _ValidateRequest(BaseModel):
|
604
569
|
name: str | None = Field(None, description="Optional server name label")
|
@@ -655,9 +620,9 @@ class OpenEdisonProxy:
|
|
655
620
|
"args": body.args,
|
656
621
|
"has_roots": bool(body.roots),
|
657
622
|
},
|
658
|
-
"tools": [self._safe_tool(t) for t in tools],
|
623
|
+
"tools": [self._safe_tool(t, prefix=server_name) for t in tools],
|
659
624
|
"resources": [self._safe_resource(r) for r in resources],
|
660
|
-
"prompts": [self._safe_prompt(p) for p in prompts],
|
625
|
+
"prompts": [self._safe_prompt(p, prefix=server_name) for p in prompts],
|
661
626
|
}
|
662
627
|
except TimeoutError as te: # noqa: PERF203
|
663
628
|
log.error(f"MCP validation timed out: {te}\n{traceback.format_exc()}")
|
@@ -728,10 +693,13 @@ class OpenEdisonProxy:
|
|
728
693
|
timeout = body.timeout_s if isinstance(body.timeout_s, (int | float)) else 20.0
|
729
694
|
return await asyncio.wait_for(list_all(), timeout=timeout)
|
730
695
|
|
731
|
-
def _safe_tool(self, t: Any) -> dict[str, Any]:
|
696
|
+
def _safe_tool(self, t: Any, prefix: str) -> dict[str, Any]:
|
732
697
|
name = getattr(t, "name", None)
|
733
698
|
description = getattr(t, "description", None)
|
734
|
-
return {
|
699
|
+
return {
|
700
|
+
"name": prefix + "_" + str(name) if name is not None else "",
|
701
|
+
"description": description,
|
702
|
+
}
|
735
703
|
|
736
704
|
def _safe_resource(self, r: Any) -> dict[str, Any]:
|
737
705
|
uri = getattr(r, "uri", None)
|
@@ -742,7 +710,10 @@ class OpenEdisonProxy:
|
|
742
710
|
description = getattr(r, "description", None)
|
743
711
|
return {"uri": uri_str, "description": description}
|
744
712
|
|
745
|
-
def _safe_prompt(self, p: Any) -> dict[str, Any]:
|
713
|
+
def _safe_prompt(self, p: Any, prefix: str) -> dict[str, Any]:
|
746
714
|
name = getattr(p, "name", None)
|
747
715
|
description = getattr(p, "description", None)
|
748
|
-
return {
|
716
|
+
return {
|
717
|
+
"name": prefix + "_" + str(name) if name is not None else "",
|
718
|
+
"description": description,
|
719
|
+
}
|
src/single_user_mcp.py
CHANGED
@@ -7,11 +7,11 @@ Handles MCP protocol communication with running servers using a unified composit
|
|
7
7
|
|
8
8
|
from typing import Any, TypedDict
|
9
9
|
|
10
|
+
from fastmcp import Client as FastMCPClient
|
10
11
|
from fastmcp import FastMCP
|
11
12
|
from loguru import logger as log
|
12
13
|
|
13
14
|
from src.config import MCPServerConfig, config
|
14
|
-
from src.mcp_manager import MCPManager
|
15
15
|
from src.middleware.session_tracking import (
|
16
16
|
SessionTrackingMiddleware,
|
17
17
|
get_current_session_data_tracker,
|
@@ -42,9 +42,8 @@ class SingleUserMCP(FastMCP[Any]):
|
|
42
42
|
All enabled MCP servers are mounted through a single FastMCP composite proxy.
|
43
43
|
"""
|
44
44
|
|
45
|
-
def __init__(self
|
45
|
+
def __init__(self):
|
46
46
|
super().__init__(name="open-edison-single-user")
|
47
|
-
self.mcp_manager: MCPManager = mcp_manager
|
48
47
|
self.mounted_servers: dict[str, MountedServerInfo] = {}
|
49
48
|
self.composite_proxy: FastMCP[Any] | None = None
|
50
49
|
|
@@ -109,119 +108,33 @@ class SingleUserMCP(FastMCP[Any]):
|
|
109
108
|
Returns:
|
110
109
|
True if composite proxy was created successfully, False otherwise
|
111
110
|
"""
|
112
|
-
|
113
|
-
|
114
|
-
real_servers = [s for s in enabled_servers if s.command != "echo"]
|
115
|
-
|
116
|
-
if not real_servers:
|
117
|
-
log.info("No real servers to mount in composite proxy")
|
118
|
-
return True
|
119
|
-
|
120
|
-
# Start all required server processes
|
121
|
-
for server_config in real_servers:
|
122
|
-
if not await self.mcp_manager.is_server_running(server_config.name):
|
123
|
-
log.info(f"Starting server process: {server_config.name}")
|
124
|
-
_ = await self.mcp_manager.start_server(server_config.name)
|
125
|
-
|
126
|
-
# Convert to FastMCP config format
|
127
|
-
fastmcp_config = self._convert_to_fastmcp_config(real_servers)
|
128
|
-
|
129
|
-
log.info(
|
130
|
-
f"Creating composite proxy for servers: {list(fastmcp_config['mcpServers'].keys())}"
|
131
|
-
)
|
132
|
-
|
133
|
-
# Create the composite proxy using FastMCP's multi-server support
|
134
|
-
self.composite_proxy = FastMCP.as_proxy(
|
135
|
-
backend=fastmcp_config, name="open-edison-composite-proxy"
|
136
|
-
)
|
137
|
-
|
138
|
-
# Import the composite proxy into this main server
|
139
|
-
# Tools and resources will be automatically namespaced by server name
|
140
|
-
await self.import_server(self.composite_proxy)
|
141
|
-
|
142
|
-
# Track mounted servers for status reporting
|
143
|
-
for server_config in real_servers:
|
144
|
-
self.mounted_servers[server_config.name] = MountedServerInfo(
|
145
|
-
config=server_config, proxy=self.composite_proxy
|
146
|
-
)
|
147
|
-
|
148
|
-
log.info(f"✅ Created composite proxy with {len(real_servers)} servers")
|
111
|
+
if not enabled_servers:
|
112
|
+
log.info("No real servers to mount in composite proxy")
|
149
113
|
return True
|
150
114
|
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
True if mounting was successful, False otherwise
|
164
|
-
"""
|
165
|
-
try:
|
166
|
-
# Check if server is already mounted
|
167
|
-
if server_config.name in self.mounted_servers:
|
168
|
-
log.info(f"Server {server_config.name} is already mounted")
|
169
|
-
return True
|
170
|
-
|
171
|
-
# Handle test servers separately
|
172
|
-
if server_config.command == "echo":
|
173
|
-
return await self._mount_test_server(server_config)
|
174
|
-
|
175
|
-
# For real servers, we need to rebuild the composite proxy
|
176
|
-
log.info(f"Mounting server {server_config.name} via composite proxy rebuild")
|
177
|
-
|
178
|
-
# Get currently mounted servers and add the new one
|
179
|
-
current_configs = [mounted["config"] for mounted in self.mounted_servers.values()]
|
180
|
-
|
181
|
-
# Add the new server if not already there
|
182
|
-
if server_config not in current_configs:
|
183
|
-
current_configs.append(server_config)
|
184
|
-
|
185
|
-
# Rebuild composite proxy with new server list
|
186
|
-
return await self.create_composite_proxy(current_configs)
|
187
|
-
|
188
|
-
except Exception as e:
|
189
|
-
log.error(f"❌ Failed to mount server {server_config.name}: {e}")
|
190
|
-
return False
|
191
|
-
|
192
|
-
async def unmount_server(self, server_name: str) -> bool:
|
193
|
-
"""
|
194
|
-
Unmount an MCP server and stop its subprocess.
|
195
|
-
|
196
|
-
NOTE: For servers in the composite proxy, this will require rebuilding
|
197
|
-
the entire composite proxy without the specified server.
|
198
|
-
"""
|
199
|
-
try:
|
200
|
-
# Check if this is a test server (individually mounted)
|
201
|
-
if server_name in self.mounted_servers:
|
202
|
-
mounted = self.mounted_servers[server_name]
|
203
|
-
if mounted["config"].command == "echo":
|
204
|
-
# Test server - handle individually
|
205
|
-
await self._cleanup_mounted_server(server_name)
|
206
|
-
return True
|
207
|
-
|
208
|
-
# Real server in composite proxy - needs full rebuild
|
209
|
-
log.warning(f"Unmounting {server_name} requires rebuilding composite proxy")
|
210
|
-
return await self._rebuild_composite_proxy_without(server_name)
|
211
|
-
|
212
|
-
log.warning(f"Server {server_name} not found in mounted servers")
|
213
|
-
return False
|
115
|
+
# Import the composite proxy into this main server
|
116
|
+
# Tools and resources will be automatically namespaced by server name
|
117
|
+
for server_config in enabled_servers:
|
118
|
+
server_name = server_config.name
|
119
|
+
# Skip if this server would produce an empty config (e.g., misconfigured)
|
120
|
+
fastmcp_config = self._convert_to_fastmcp_config([server_config])
|
121
|
+
if not fastmcp_config.get("mcpServers"):
|
122
|
+
log.warning(f"Skipping server '{server_name}' due to empty MCP config")
|
123
|
+
continue
|
124
|
+
proxy = FastMCP.as_proxy(FastMCPClient(fastmcp_config))
|
125
|
+
self.mount(proxy, prefix=server_name)
|
126
|
+
self.mounted_servers[server_name] = MountedServerInfo(config=server_config, proxy=proxy)
|
214
127
|
|
215
|
-
|
216
|
-
|
217
|
-
|
128
|
+
log.info(
|
129
|
+
f"✅ Created composite proxy with {len(enabled_servers)} servers ({self.mounted_servers.keys()})"
|
130
|
+
)
|
131
|
+
return True
|
218
132
|
|
219
133
|
async def _rebuild_composite_proxy_without(self, excluded_server: str) -> bool:
|
220
134
|
"""Rebuild the composite proxy without the specified server."""
|
221
135
|
try:
|
222
136
|
# Remove from mounted servers
|
223
137
|
await self._cleanup_mounted_server(excluded_server)
|
224
|
-
await self._stop_server_process(excluded_server)
|
225
138
|
|
226
139
|
# Get remaining servers that should be in composite proxy
|
227
140
|
remaining_configs = [
|
@@ -245,15 +158,11 @@ class SingleUserMCP(FastMCP[Any]):
|
|
245
158
|
|
246
159
|
async def _cleanup_mounted_server(self, server_name: str) -> None:
|
247
160
|
"""Clean up mounted server resources."""
|
161
|
+
# TODO not sure this is possible for the self object? i.e. there is no self.unmount
|
248
162
|
if server_name in self.mounted_servers:
|
249
163
|
del self.mounted_servers[server_name]
|
250
164
|
log.info(f"✅ Unmounted MCP server: {server_name}")
|
251
165
|
|
252
|
-
async def _stop_server_process(self, server_name: str) -> None:
|
253
|
-
"""Stop the server subprocess if it's not a test server."""
|
254
|
-
if server_name != "test-echo":
|
255
|
-
await self.mcp_manager.stop_server(server_name)
|
256
|
-
|
257
166
|
async def get_mounted_servers(self) -> list[ServerStatusInfo]:
|
258
167
|
"""Get list of currently mounted servers."""
|
259
168
|
return [
|
@@ -287,6 +196,80 @@ class SingleUserMCP(FastMCP[Any]):
|
|
287
196
|
|
288
197
|
log.info("✅ Single User MCP server initialized with composite proxy")
|
289
198
|
|
199
|
+
async def reinitialize(self, test_config: Any | None = None) -> dict[str, Any]:
|
200
|
+
"""
|
201
|
+
Reinitialize all MCP servers by cleaning up existing ones and reloading config.
|
202
|
+
|
203
|
+
This method:
|
204
|
+
1. Cleans up all mounted servers and MCP proxies
|
205
|
+
2. Reloads the configuration
|
206
|
+
3. Reinitializes all enabled servers
|
207
|
+
|
208
|
+
Args:
|
209
|
+
test_config: Optional test configuration to use instead of reloading from disk
|
210
|
+
|
211
|
+
Returns:
|
212
|
+
Dictionary with reinitialization status and details
|
213
|
+
"""
|
214
|
+
log.info("🔄 Reinitializing all MCP servers")
|
215
|
+
|
216
|
+
try:
|
217
|
+
# Step 1: Clean up existing mounted servers and proxies
|
218
|
+
log.info("Cleaning up existing mounted servers and proxies")
|
219
|
+
|
220
|
+
# Clean up composite proxy if it exists
|
221
|
+
if self.composite_proxy is not None:
|
222
|
+
log.info("Cleaning up composite proxy")
|
223
|
+
self.composite_proxy = None
|
224
|
+
|
225
|
+
# Clean up all mounted servers
|
226
|
+
mounted_server_names = list(self.mounted_servers.keys())
|
227
|
+
for server_name in mounted_server_names:
|
228
|
+
await self._cleanup_mounted_server(server_name)
|
229
|
+
|
230
|
+
# Clear the mounted servers dictionary completely
|
231
|
+
self.mounted_servers.clear()
|
232
|
+
|
233
|
+
log.info(f"✅ Cleaned up {len(mounted_server_names)} mounted servers")
|
234
|
+
|
235
|
+
# Step 2: Reload configuration if not using test config
|
236
|
+
config_to_use = test_config
|
237
|
+
if test_config is None:
|
238
|
+
log.info("Reloading configuration from disk")
|
239
|
+
# Import here to avoid circular imports
|
240
|
+
from src.config import Config
|
241
|
+
|
242
|
+
config_to_use = Config.load()
|
243
|
+
log.info("✅ Configuration reloaded from disk")
|
244
|
+
|
245
|
+
# Step 3: Reinitialize all servers
|
246
|
+
log.info("Reinitializing servers with fresh configuration")
|
247
|
+
await self.initialize(config_to_use)
|
248
|
+
|
249
|
+
# Step 4: Get final status
|
250
|
+
final_mounted = await self.get_mounted_servers()
|
251
|
+
|
252
|
+
result = {
|
253
|
+
"status": "success",
|
254
|
+
"message": "MCP servers reinitialized successfully",
|
255
|
+
"cleaned_up_servers": mounted_server_names,
|
256
|
+
"final_mounted_servers": [server["name"] for server in final_mounted],
|
257
|
+
"total_final_mounted": len(final_mounted),
|
258
|
+
}
|
259
|
+
|
260
|
+
log.info(
|
261
|
+
f"✅ Reinitialization complete. Final mounted servers: {result['final_mounted_servers']}"
|
262
|
+
)
|
263
|
+
return result
|
264
|
+
|
265
|
+
except Exception as e:
|
266
|
+
log.error(f"❌ Failed to reinitialize MCP servers: {e}")
|
267
|
+
return {
|
268
|
+
"status": "error",
|
269
|
+
"message": f"Failed to reinitialize MCP servers: {str(e)}",
|
270
|
+
"error": str(e),
|
271
|
+
}
|
272
|
+
|
290
273
|
def _calculate_risk_level(self, trifecta: dict[str, bool]) -> str:
|
291
274
|
"""
|
292
275
|
Calculate a human-readable risk level based on trifecta flags.
|