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.
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.mcp_manager: MCPManager = MCPManager()
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 (and writes if chosen):
188
- 1) Repository root next to src/ (editable/dev) if file exists
189
- 2) Config dir (OPEN_EDISON_CONFIG_DIR or platform default)
190
- - If missing, bootstrap from repo root default when available
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) Prefer repository root next to src/
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 (not target.exists()) and repo_candidate.exists():
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
- return target if target.exists() else repo_candidate
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
- base = Path.cwd()
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 = base / filename
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
- _ = _json.loads(content or "{}")
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/{server_name}/mount",
399
- self.mount_server,
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 mount_server(self, server_name: str) -> dict[str, str]:
524
- """Mount a specific MCP server."""
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
- server_config = self._find_server_config(server_name)
527
- success = await self.single_user_mcp.mount_server(server_config)
528
- if success:
529
- return {"message": f"Server {server_name} mounted successfully"}
530
- raise HTTPException(
531
- status_code=500,
532
- detail=f"Failed to mount server: {server_name}",
533
- )
534
- except HTTPException:
535
- raise
536
- except Exception as e:
537
- raise self._handle_server_operation_error("mount", server_name, e) from e
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
- raise self._handle_server_operation_error("unmount", server_name, e) from e
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 {"name": str(name) if name is not None else "", "description": description}
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 {"name": str(name) if name is not None else "", "description": description}
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, mcp_manager: MCPManager):
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
- try:
113
- # Filter out test servers for composite proxy
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
- except Exception as e:
152
- log.error(f"❌ Failed to create composite proxy: {e}")
153
- return False
154
-
155
- async def mount_server(self, server_config: MCPServerConfig) -> bool:
156
- """
157
- Mount a single MCP server by rebuilding the composite proxy.
158
-
159
- Args:
160
- server_config: Configuration for the server to mount
161
-
162
- Returns:
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
- except Exception as e:
216
- log.error(f" Failed to unmount MCP server {server_name}: {e}")
217
- return False
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.