Skip to main content
Glama

MCP Terminal Server

by cwmoore-ai
service.py17.9 kB
""" MCP Terminal Server - Windows Service Wrapper This module provides a Windows service wrapper for the MCP Terminal Server FastAPI application. It handles service installation, removal, starting, stopping, and runtime management. The service can be configured through: 1. Environment variables 2. Configuration file (config.json) 3. Command-line arguments Usage: python service.py install # Install the service python service.py start # Start the service python service.py stop # Stop the service python service.py remove # Remove the service python service.py update # Update the service configuration python service.py debug # Run in debug mode (not as a service) Author: MCP Terminal Server Team License: MIT """ import json import logging import logging.handlers import os import pathlib import signal import subprocess import sys import time from typing import Dict, List, Optional, Union, Any import win32event import win32service import win32serviceutil import servicemanager # Configure logging LOG_LEVELS = { "debug": logging.DEBUG, "info": logging.INFO, "warning": logging.WARNING, "error": logging.ERROR, "critical": logging.CRITICAL } # Default configuration DEFAULT_CONFIG = { "host": "0.0.0.0", "port": 8000, "log_level": "info", "log_file": "mcp_terminal_server.log", "max_log_size_mb": 10, "log_backup_count": 5, "restart_on_failure": True, "max_restart_attempts": 5, "restart_delay_seconds": 10, "shutdown_timeout_seconds": 30, "working_directory": None # Will be set to script directory if None } class ConfigManager: """ Manages configuration for the MCP Terminal Server service. Loads configuration from: 1. Default values 2. Configuration file (if exists) 3. Environment variables (overrides file settings) """ def __init__(self, config_file: str = "config.json"): """ Initialize the configuration manager. Args: config_file: Path to the configuration file (relative to working directory) """ self.config_file = config_file self.config = DEFAULT_CONFIG.copy() # Set working directory to script directory by default if not self.config["working_directory"]: self.config["working_directory"] = os.path.dirname(os.path.abspath(__file__)) # Load configuration from file if it exists self._load_from_file() # Override with environment variables self._load_from_env() def _load_from_file(self) -> None: """Load configuration from the config file if it exists.""" try: if os.path.exists(self.config_file): with open(self.config_file, 'r') as f: file_config = json.load(f) self.config.update(file_config) except Exception as e: # Just log the error, don't fail (use defaults) print(f"Warning: Failed to load config file: {e}") def _load_from_env(self) -> None: """Load configuration from environment variables.""" env_mapping = { "MCP_HOST": "host", "MCP_PORT": ("port", int), "MCP_LOG_LEVEL": "log_level", "MCP_LOG_FILE": "log_file", "MCP_MAX_LOG_SIZE_MB": ("max_log_size_mb", int), "MCP_LOG_BACKUP_COUNT": ("log_backup_count", int), "MCP_RESTART_ON_FAILURE": ("restart_on_failure", lambda x: x.lower() == "true"), "MCP_MAX_RESTART_ATTEMPTS": ("max_restart_attempts", int), "MCP_RESTART_DELAY_SECONDS": ("restart_delay_seconds", int), "MCP_SHUTDOWN_TIMEOUT_SECONDS": ("shutdown_timeout_seconds", int), "MCP_WORKING_DIRECTORY": "working_directory" } for env_var, config_key in env_mapping.items(): # Check if the environment variable exists if env_var in os.environ: # Handle conversion if needed if isinstance(config_key, tuple): key_name, converter = config_key try: self.config[key_name] = converter(os.environ[env_var]) except (ValueError, TypeError): print(f"Warning: Failed to convert environment variable {env_var}") else: self.config[key_name] = os.environ[env_var] def get(self, key: str, default: Any = None) -> Any: """ Get a configuration value. Args: key: The configuration key default: Default value if key doesn't exist Returns: The configuration value or default """ return self.config.get(key, default) def save(self) -> None: """Save the current configuration to the config file.""" try: with open(self.config_file, 'w') as f: json.dump(self.config, f, indent=4) except Exception as e: print(f"Error saving configuration: {e}") class ServiceLogger: """ Handles logging for the MCP Terminal Server service. Features: - Log rotation - Multiple log levels - Console and file output """ def __init__(self, config: ConfigManager): """ Initialize the logger. Args: config: Configuration manager instance """ self.config = config self.logger = logging.getLogger("mcp_terminal_server") # Set log level log_level_name = config.get("log_level", "info").lower() log_level = LOG_LEVELS.get(log_level_name, logging.INFO) self.logger.setLevel(log_level) # Clear existing handlers self.logger.handlers = [] # Create formatter formatter = logging.Formatter( "%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) # Add file handler with rotation log_file = config.get("log_file") if log_file: # Ensure log file path is absolute if not os.path.isabs(log_file): log_file = os.path.join(config.get("working_directory"), log_file) # Ensure log directory exists log_dir = os.path.dirname(log_file) if log_dir and not os.path.exists(log_dir): os.makedirs(log_dir, exist_ok=True) # Create rotating file handler max_bytes = config.get("max_log_size_mb", 10) * 1024 * 1024 backup_count = config.get("log_backup_count", 5) file_handler = logging.handlers.RotatingFileHandler( log_file, maxBytes=max_bytes, backupCount=backup_count ) file_handler.setFormatter(formatter) self.logger.addHandler(file_handler) # Add console handler for debug mode if "debug" in sys.argv: console_handler = logging.StreamHandler() console_handler.setFormatter(formatter) self.logger.addHandler(console_handler) def debug(self, message: str) -> None: """Log a debug message.""" self.logger.debug(message) def info(self, message: str) -> None: """Log an info message.""" self.logger.info(message) def warning(self, message: str) -> None: """Log a warning message.""" self.logger.warning(message) def error(self, message: str) -> None: """Log an error message.""" self.logger.error(message) def critical(self, message: str) -> None: """Log a critical message.""" self.logger.critical(message) class ProcessManager: """ Manages the FastAPI server process. Features: - Process startup and shutdown - Restart on failure - Health monitoring """ def __init__(self, config: ConfigManager, logger: ServiceLogger): """ Initialize the process manager. Args: config: Configuration manager instance logger: Service logger instance """ self.config = config self.logger = logger self.process = None self.restart_count = 0 self.running = False def start(self) -> bool: """ Start the FastAPI server process. Returns: True if started successfully, False otherwise """ if self.process and self.process.poll() is None: self.logger.warning("Process already running") return True try: # Set working directory working_dir = self.config.get("working_directory") if working_dir and os.path.exists(working_dir): os.chdir(working_dir) self.logger.info(f"Changed working directory to: {working_dir}") # Build command python_exe = sys.executable host = self.config.get("host") port = self.config.get("port") uvicorn_cmd = [ python_exe, "-m", "uvicorn", "main:app", "--host", str(host), "--port", str(port) ] # Start the process self.logger.info(f"Starting FastAPI server: {' '.join(uvicorn_cmd)}") self.process = subprocess.Popen( uvicorn_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=working_dir ) # Check if process started successfully if self.process.poll() is None: self.logger.info(f"FastAPI server started with PID: {self.process.pid}") self.running = True self.restart_count = 0 return True else: self.logger.error(f"Failed to start FastAPI server, exit code: {self.process.returncode}") return False except Exception as e: self.logger.error(f"Error starting FastAPI server: {str(e)}") return False def stop(self) -> bool: """ Stop the FastAPI server process gracefully. Returns: True if stopped successfully, False otherwise """ if not self.process: self.logger.warning("No process to stop") self.running = False return True try: # Try graceful shutdown first self.logger.info("Attempting graceful shutdown...") # Send SIGTERM (graceful shutdown) self.process.terminate() # Wait for process to terminate shutdown_timeout = self.config.get("shutdown_timeout_seconds", 30) for _ in range(shutdown_timeout): if self.process.poll() is not None: self.logger.info(f"Process terminated gracefully with exit code: {self.process.returncode}") self.running = False return True time.sleep(1) # Force kill if still running self.logger.warning(f"Process did not terminate after {shutdown_timeout} seconds, forcing kill") self.process.kill() # Wait for process to be killed self.process.wait(timeout=5) self.logger.info(f"Process killed with exit code: {self.process.returncode}") self.running = False return True except Exception as e: self.logger.error(f"Error stopping process: {str(e)}") self.running = False return False def restart(self) -> bool: """ Restart the FastAPI server process. Returns: True if restarted successfully, False otherwise """ self.logger.info("Restarting FastAPI server...") self.stop() return self.start() def check_health(self) -> bool: """ Check if the process is healthy. Returns: True if healthy, False otherwise """ if not self.process: return False # Check if process is still running if self.process.poll() is not None: self.logger.warning(f"Process exited with code: {self.process.returncode}") # Handle restart on failure if self.config.get("restart_on_failure", True): max_attempts = self.config.get("max_restart_attempts", 5) if self.restart_count < max_attempts: self.restart_count += 1 delay = self.config.get("restart_delay_seconds", 10) self.logger.info(f"Restarting in {delay} seconds (attempt {self.restart_count}/{max_attempts})...") time.sleep(delay) return self.start() else: self.logger.error(f"Exceeded maximum restart attempts ({max_attempts})") return False else: return False return True class MCPServerService(win32serviceutil.ServiceFramework): """ Windows service implementation for the MCP Terminal Server. This class handles the Windows service lifecycle events and manages the FastAPI server process. """ _svc_name_ = "MCP_Terminal_Server" _svc_display_name_ = "MCP Terminal Server" _svc_description_ = "Runs a lightweight MCP server for live terminal commands." def __init__(self, args): """ Initialize the service. Args: args: Service arguments """ win32serviceutil.ServiceFramework.__init__(self, args) self.hWaitStop = win32event.CreateEvent(None, 0, 0, None) self.config = ConfigManager() self.logger = ServiceLogger(self.config) self.process_manager = ProcessManager(self.config, self.logger) # Set up signal handlers signal.signal(signal.SIGTERM, self._handle_signal) signal.signal(signal.SIGINT, self._handle_signal) def _handle_signal(self, signum, frame): """ Handle termination signals. Args: signum: Signal number frame: Current stack frame """ self.logger.info(f"Received signal {signum}, stopping service") self.SvcStop() def SvcStop(self): """Handle service stop request.""" self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) self.logger.info("Service stop requested") # Stop the FastAPI server self.process_manager.stop() # Signal the main thread to exit win32event.SetEvent(self.hWaitStop) self.logger.info("Service stopped") def SvcDoRun(self): """Run the service.""" servicemanager.LogMsg( servicemanager.EVENTLOG_INFORMATION_TYPE, servicemanager.PYS_SERVICE_STARTED, (self._svc_name_, '') ) self.logger.info(f"Starting {self._svc_display_name_} service") try: # Start the FastAPI server if not self.process_manager.start(): self.logger.critical("Failed to start FastAPI server, service will exit") return # Main service loop while True: # Wait for service stop signal or check interval result = win32event.WaitForSingleObject(self.hWaitStop, 5000) # 5 second check interval # If stop signal received if result == win32event.WAIT_OBJECT_0: break # Check process health if not self.process_manager.check_health(): self.logger.critical("FastAPI server is not healthy and cannot be restarted") break except Exception as e: self.logger.critical(f"Service error: {str(e)}") finally: # Ensure process is stopped self.process_manager.stop() self.logger.info("Service run completed") def run_debug_mode(): """Run the server in debug mode (not as a service).""" print("Running in debug mode (press Ctrl+C to stop)...") config = ConfigManager() logger = ServiceLogger(config) process_manager = ProcessManager(config, logger) logger.info("Starting server in debug mode") try: if not process_manager.start(): logger.critical("Failed to start server in debug mode") return logger.info("Server running in debug mode. Press Ctrl+C to stop.") # Main loop while process_manager.running: time.sleep(1) process_manager.check_health() except KeyboardInterrupt: logger.info("Keyboard interrupt received, stopping server") except Exception as e: logger.critical(f"Error in debug mode: {str(e)}") finally: process_manager.stop() logger.info("Debug mode stopped") if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1].lower() == "debug": run_debug_mode() else: win32serviceutil.HandleCommandLine(MCPServerService)

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/cwmoore-ai/mcp-terminal-server'

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