Skip to main content
Glama
daemon_manager.py26.5 kB
# # MCP Foxxy Bridge - Daemon Management # # Copyright (C) 2024 Billy Bryant # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. # """Daemon process management utilities.""" import asyncio import contextlib import hashlib import json import os import socket import subprocess import sys from datetime import datetime from pathlib import Path from typing import Any import psutil from rich.console import Console from mcp_foxxy_bridge.config.config_loader import load_bridge_config_from_file class DaemonManager: """Manage the bridge daemon process.""" @staticmethod def generate_daemon_name(config_file: str | Path) -> str: """Generate a unique daemon name based on the config file path. Args: config_file: Path to the configuration file Returns: A short, unique daemon name """ config_path = Path(config_file).resolve() # Create a short hash from the absolute path path_hash = hashlib.md5(str(config_path).encode()).hexdigest()[:8] # Use the config file stem (name without extension) plus hash config_stem = config_path.stem return f"{config_stem}-{path_hash}" @staticmethod def find_running_bridge_processes() -> list[dict[str, Any]]: """Find all running bridge processes by scanning the process table. Returns: List of process info dictionaries for running bridge instances """ running_processes = [] try: for proc in psutil.process_iter(["pid", "name", "cmdline", "create_time"]): # type: ignore[no-untyped-call] try: cmdline = proc.info["cmdline"] if not cmdline: continue # Look for bridge server processes only cmdline_str = " ".join(cmdline) # Match only actual bridge server invocations by looking at the executed command executable = cmdline[0] if cmdline else "" # Check if this is a bridge server process is_bridge = False # Direct invocation of bridge commands if executable.endswith(("foxxy-bridge", "mcp-foxxy-bridge")): is_bridge = True # Python module invocation elif executable.endswith(("python", "python3")): if len(cmdline) > 2 and cmdline[1] == "-m" and "mcp_foxxy_bridge" in cmdline[2]: is_bridge = True if is_bridge: # Extract configuration info from command line config_file = None port = None host = None name = None # Parse command line arguments for i, arg in enumerate(cmdline): if arg in ["--bridge-config", "-c"] and i + 1 < len(cmdline): config_file = cmdline[i + 1] elif arg in ["--port", "-p"] and i + 1 < len(cmdline): with contextlib.suppress(ValueError): port = int(cmdline[i + 1]) elif arg in ["--host"] and i + 1 < len(cmdline): host = cmdline[i + 1] elif arg in ["--name", "-n"] and i + 1 < len(cmdline): name = cmdline[i + 1] process_info = { "pid": proc.info["pid"], "name": name or f"foreground-{proc.info['pid']}", "status": "running", "type": "foreground", "config_file": config_file, "port": port, "host": host, "started_at": str(int(proc.info["create_time"])), "cmdline": cmdline_str, } running_processes.append(process_info) except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): # Process may have disappeared or we don't have access continue except Exception: # If process scanning fails entirely, just return empty list pass return running_processes @staticmethod def list_daemons(config_dir: Path) -> list[dict[str, Any]]: """List all running bridge instances (both daemon and foreground). Args: config_dir: Configuration directory to search Returns: List of daemon/process info dictionaries """ daemons = [] daemon_pids = set() # Find all daemon info files for info_file in config_dir.glob("bridge-*.json"): try: with info_file.open("r") as f: daemon_info = json.load(f) # Check if process is still running pid = daemon_info.get("pid") if pid and psutil.pid_exists(pid): # type: ignore[no-untyped-call] daemon_info["status"] = "running" daemon_info["type"] = "daemon" daemons.append(daemon_info) daemon_pids.add(pid) else: # Process is dead, clean up stale files and skip daemon_info["status"] = "stopped" try: # Extract daemon name from file name daemon_name = info_file.stem.replace("bridge-", "") info_file.unlink() # Remove .json file # Also remove corresponding PID file pid_file = config_dir / f"bridge-{daemon_name}.pid" if pid_file.exists(): pid_file.unlink() except (PermissionError, OSError): # If cleanup fails, still show the stopped daemon daemons.append(daemon_info) except (json.JSONDecodeError, FileNotFoundError, PermissionError): # Skip invalid or inaccessible files continue # Also check for default daemon default_info_file = config_dir / "bridge.json" if default_info_file.exists(): try: with default_info_file.open("r") as f: daemon_info = json.load(f) # Check if process is still running pid = daemon_info.get("pid") if pid and psutil.pid_exists(pid): # type: ignore[no-untyped-call] daemon_info["status"] = "running" daemon_info["type"] = "daemon" daemons.append(daemon_info) daemon_pids.add(pid) else: # Process is dead, clean up stale files and skip daemon_info["status"] = "stopped" try: default_info_file.unlink() # Remove .json file # Also remove corresponding PID file pid_file = config_dir / "bridge.pid" if pid_file.exists(): pid_file.unlink() except (PermissionError, OSError): # If cleanup fails, still show the stopped daemon daemons.append(daemon_info) except (json.JSONDecodeError, FileNotFoundError, PermissionError): pass # Find running foreground processes not already tracked as daemons daemons.extend( process_info for process_info in DaemonManager.find_running_bridge_processes() if process_info["pid"] not in daemon_pids ) return daemons @staticmethod def get_daemon_info_by_name(config_dir: Path, daemon_name: str) -> dict[str, Any] | None: """Get daemon info by name without loading config. Args: config_dir: Configuration directory daemon_name: Name of the daemon Returns: Daemon info dictionary or None if not found """ if daemon_name == "default": info_file = config_dir / "bridge.json" else: info_file = config_dir / f"bridge-{daemon_name}.json" # First try to find in daemon files if info_file.exists(): try: with info_file.open("r") as f: daemon_info = json.load(f) # Check if process is still running pid = daemon_info.get("pid") if pid and psutil.pid_exists(pid): # type: ignore[no-untyped-call] daemon_info["status"] = "running" daemon_info["type"] = "daemon" try: process = psutil.Process(pid) # type: ignore[no-untyped-call] daemon_info["memory_info"] = process.memory_info()._asdict() daemon_info["cpu_percent"] = process.cpu_percent() # type: ignore[no-untyped-call] daemon_info["create_time"] = process.create_time() # type: ignore[no-untyped-call] except psutil.NoSuchProcess: daemon_info["status"] = "stopped" # Clean up stale daemon files _cleanup_stale_daemon_files(config_dir, daemon_name) else: daemon_info["status"] = "stopped" # Clean up stale daemon files _cleanup_stale_daemon_files(config_dir, daemon_name) return daemon_info # type: ignore[no-any-return] except (json.JSONDecodeError, FileNotFoundError, PermissionError): pass # If not found in daemon files, check running processes for process_info in DaemonManager.find_running_bridge_processes(): if process_info["name"] == daemon_name: return process_info return None def __init__(self, config_dir: Path, console: Console | None = None, daemon_name: str | None = None) -> None: """Initialize daemon manager. Args: config_dir: Configuration directory console: Rich console for output daemon_name: Optional daemon name for named daemons """ self.config_dir = config_dir self.console = console or Console() self.daemon_name = daemon_name # Use daemon name for file naming if provided if daemon_name: self.pid_file = config_dir / f"bridge-{daemon_name}.pid" self.log_file = config_dir / f"bridge-{daemon_name}.log" self.info_file = config_dir / f"bridge-{daemon_name}.json" else: self.pid_file = config_dir / "bridge.pid" self.log_file = config_dir / "bridge.log" self.info_file = config_dir / "bridge.json" async def start_daemon( self, config_file: str | None = None, host: str | None = None, port: int | None = None, detach: bool = True, debug: bool = False, ) -> bool: """Start the bridge daemon. Args: config_file: Configuration file path host: Server host override port: Server port override detach: Whether to run in background (daemon mode) debug: Whether to enable debug logging Returns: True if started successfully """ if await self.is_running(): self.console.print("[yellow]Bridge daemon is already running[/yellow]") return False # Build command cmd = [sys.executable, "-m", "mcp_foxxy_bridge"] if debug: cmd.append("--debug") if config_file: cmd.extend(["--bridge-config", config_file]) else: # Use default config default_config = self.config_dir / "config.json" if default_config.exists(): cmd.extend(["--bridge-config", str(default_config)]) if host: cmd.extend(["--host", host]) if port: cmd.extend(["--port", str(port)]) # Ensure log directory exists self.config_dir.mkdir(parents=True, exist_ok=True) if detach: # Start daemon in background try: with self.log_file.open("w") as log_f: process = subprocess.Popen( cmd, stdout=log_f, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, start_new_session=True, # This already creates a new session on Unix ) # Write PID file with self.pid_file.open("w") as pid_f: pid_f.write(str(process.pid)) # Write daemon info file daemon_info = { "pid": process.pid, "name": self.daemon_name or "default", "config_file": str(Path(config_file).resolve()) if config_file else None, "started_at": datetime.now().isoformat(), "host": host, "port": port, "log_file": str(self.log_file), "pid_file": str(self.pid_file), } with self.info_file.open("w") as info_f: json.dump(daemon_info, info_f, indent=2) # Give it a moment to start, then detach completely await asyncio.sleep(1) # Reduced wait time # Detach from the process - don't maintain reference pid = process.pid process = None # type: ignore[assignment] # Release the process reference # Quick check if process exists without blocking return bool(await self._is_process_running(pid)) except Exception as e: self.console.print(f"[red]Failed to start daemon: {e}[/red]") return False else: # Start in foreground try: result = subprocess.run(cmd, check=False) return result.returncode == 0 except Exception as e: self.console.print(f"[red]Failed to start daemon: {e}[/red]") return False async def stop_daemon(self, force: bool = False) -> bool: """Stop the bridge daemon. Args: force: Force stop with SIGKILL Returns: True if stopped successfully """ pid = await self._get_daemon_pid() if not pid: self.console.print("[yellow]Bridge daemon is not running[/yellow]") return True try: process = psutil.Process(pid) # type: ignore[no-untyped-call] if force: process.kill() # type: ignore[no-untyped-call] self.console.print(f"[red]Forcibly killed[/red] daemon (PID: {pid})") else: process.terminate() # type: ignore[no-untyped-call] # Wait for graceful shutdown try: process.wait(timeout=10) # type: ignore[no-untyped-call] self.console.print(f"[green]✓[/green] Stopped daemon (PID: {pid})") except psutil.TimeoutExpired: self.console.print("[yellow]Daemon didn't stop gracefully, forcing...[/yellow]") process.kill() # type: ignore[no-untyped-call] process.wait(timeout=5) # type: ignore[no-untyped-call] self.console.print(f"[red]Forcibly stopped[/red] daemon (PID: {pid})") # Clean up PID file if self.pid_file.exists(): self.pid_file.unlink() # Wait for daemon to be fully stopped and port to be released if not await self._wait_for_daemon_stopped(): self.console.print("[yellow]Warning: Daemon may not be fully stopped[/yellow]") host, port = await self._get_bridge_host_port() if not await self._wait_for_port_available(host, port): self.console.print(f"[yellow]Warning: Port {port} on {host} may still be in use[/yellow]") return True except psutil.NoSuchProcess: self.console.print("[yellow]Daemon process no longer exists[/yellow]") if self.pid_file.exists(): self.pid_file.unlink() return True except Exception as e: self.console.print(f"[red]Failed to stop daemon: {e}[/red]") return False async def restart_daemon(self, force: bool = False, **start_kwargs: Any) -> bool: """Restart the bridge daemon. Args: force: Force stop before restart **start_kwargs: Arguments for start_daemon Returns: True if restarted successfully """ if await self.is_running(): if not await self.stop_daemon(force): return False # Wait for port to be available host, port = await self._get_bridge_host_port() if not await self._wait_for_port_available(host, port): self.console.print(f"[red]Error: Port {port} on {host} is still in use after stopping daemon[/red]") return False return await self.start_daemon(**start_kwargs) async def _wait_for_port_available(self, host: str = "127.0.0.1", port: int = 9090, timeout: int = 30) -> bool: """Wait for a port to become available. Args: host: Host to check (default: 127.0.0.1) port: Port number to check timeout: Maximum time to wait in seconds Returns: True if port became available, False if timeout """ start_time = asyncio.get_event_loop().time() while (asyncio.get_event_loop().time() - start_time) < timeout: try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) return True except OSError: await asyncio.sleep(0.5) return False async def _wait_for_daemon_stopped(self, timeout: int = 30) -> bool: """Wait for daemon to be fully stopped. Args: timeout: Maximum time to wait in seconds Returns: True if daemon stopped, False if timeout """ start_time = asyncio.get_event_loop().time() while (asyncio.get_event_loop().time() - start_time) < timeout: if not await self.is_running(): return True await asyncio.sleep(0.5) return False async def _get_bridge_host_port(self) -> tuple[str, int]: """Get bridge host and port from config. Returns: Tuple of (host, port) from config, defaults to ("127.0.0.1", 9090) """ try: # Try to find config file - look in common locations config_paths = [ Path.home() / ".config" / "foxxy-bridge" / "config.json", Path.cwd() / "config.json", ] for config_path in config_paths: if config_path.exists(): try: bridge_config = load_bridge_config_from_file(str(config_path), dict(os.environ)) if bridge_config and bridge_config.bridge: configured_host = getattr(bridge_config.bridge, "host", "127.0.0.1") # If server binds to 0.0.0.0, check port on localhost instead host = "127.0.0.1" if configured_host == "0.0.0.0" else configured_host # noqa: S104 port = getattr(bridge_config.bridge, "port", 9090) return (host, port) except Exception: pass except Exception: pass return ("127.0.0.1", 9090) async def get_daemon_status(self) -> dict[str, Any]: """Get daemon status information. Returns: Dictionary with daemon status details """ pid = await self._get_daemon_pid() if not pid: return {"status": "stopped", "pid": None} try: process = psutil.Process(pid) # type: ignore[no-untyped-call] # Get process info info = { "status": "running", "pid": pid, "name": process.name(), # type: ignore[no-untyped-call] "create_time": process.create_time(), # type: ignore[no-untyped-call] "memory_info": process.memory_info()._asdict(), "cpu_percent": process.cpu_percent(), # type: ignore[no-untyped-call] } # Try to get connection info if available try: connections = process.connections() for conn in connections: if conn.status == "LISTEN": info["host"] = conn.laddr.ip info["port"] = conn.laddr.port break except (psutil.AccessDenied, psutil.NoSuchProcess): pass return info except psutil.NoSuchProcess: # Process no longer exists, clean up if self.pid_file.exists(): self.pid_file.unlink() return {"status": "stopped", "pid": None} except Exception as e: return {"status": "error", "pid": pid, "error": str(e)} async def is_running(self) -> bool: """Check if daemon is running. Returns: True if daemon is running """ pid = await self._get_daemon_pid() return bool(pid and await self._is_process_running(pid)) async def get_log_content(self, lines: int = 50) -> str: """Get daemon log content. Args: lines: Number of lines to read from end Returns: Log content as string """ if not self.log_file.exists(): return "No log file found" try: # Read last N lines with self.log_file.open("r") as f: all_lines = f.readlines() return "".join(all_lines[-lines:]) except Exception as e: return f"Error reading log: {e}" async def follow_logs(self, console: Console) -> None: """Follow daemon logs in real-time. Args: console: Rich console for output """ if not self.log_file.exists(): console.print("[red]No log file found[/red]") return try: # Start from end of file with self.log_file.open("r") as f: # Go to end f.seek(0, 2) console.print(f"[dim]Following logs: {self.log_file}[/dim]") console.print("[dim]Press Ctrl+C to stop[/dim]\n") while True: line = f.readline() if line: console.print(line.rstrip()) else: await asyncio.sleep(0.1) except KeyboardInterrupt: console.print("\n[yellow]Stopped following logs[/yellow]") except Exception as e: console.print(f"[red]Error following logs: {e}[/red]") async def _get_daemon_pid(self) -> int | None: """Get daemon PID from file. Returns: PID if found and valid, None otherwise """ if not self.pid_file.exists(): return None try: with self.pid_file.open("r") as f: pid_str = f.read().strip() return int(pid_str) if pid_str else None except (ValueError, FileNotFoundError): return None async def _is_process_running(self, pid: int) -> bool: """Check if process with PID is running. Args: pid: Process ID Returns: True if process is running """ try: return psutil.pid_exists(pid) # type: ignore[no-untyped-call, no-any-return] except Exception: return False def _cleanup_stale_daemon_files(config_dir: Path, daemon_name: str) -> None: """Clean up stale daemon files when process is confirmed dead.""" try: if daemon_name == "default": files_to_remove = [ config_dir / "bridge.pid", config_dir / "bridge.json", ] else: files_to_remove = [ config_dir / f"bridge-{daemon_name}.pid", config_dir / f"bridge-{daemon_name}.json", ] for file_path in files_to_remove: if file_path.exists(): try: file_path.unlink() except (PermissionError, OSError): # Ignore cleanup failures - don't break status checking pass except Exception: # Ignore cleanup failures - don't break status checking pass

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/billyjbryant/mcp-foxxy-bridge'

If you have feedback or need assistance with the MCP directory API, please join our Discord server