using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Sockets; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Services.Server; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Services { /// /// Service for managing MCP server lifecycle /// public class ServerManagementService : IServerManagementService { private readonly IProcessDetector _processDetector; private readonly IPidFileManager _pidFileManager; private readonly IProcessTerminator _processTerminator; private readonly IServerCommandBuilder _commandBuilder; private readonly ITerminalLauncher _terminalLauncher; /// /// Creates a new ServerManagementService with default dependencies. /// public ServerManagementService() : this(null, null, null, null, null) { } /// /// Creates a new ServerManagementService with injected dependencies (for testing). /// /// Process detector implementation (null for default) /// PID file manager implementation (null for default) /// Process terminator implementation (null for default) /// Server command builder implementation (null for default) /// Terminal launcher implementation (null for default) public ServerManagementService( IProcessDetector processDetector, IPidFileManager pidFileManager = null, IProcessTerminator processTerminator = null, IServerCommandBuilder commandBuilder = null, ITerminalLauncher terminalLauncher = null) { _processDetector = processDetector ?? new ProcessDetector(); _pidFileManager = pidFileManager ?? new PidFileManager(); _processTerminator = processTerminator ?? new ProcessTerminator(_processDetector); _commandBuilder = commandBuilder ?? new ServerCommandBuilder(); _terminalLauncher = terminalLauncher ?? new TerminalLauncher(); } private string QuoteIfNeeded(string s) { return _commandBuilder.QuoteIfNeeded(s); } private string NormalizeForMatch(string s) { return _processDetector.NormalizeForMatch(s); } private void ClearLocalServerPidTracking() { _pidFileManager.ClearTracking(); } private void StoreLocalHttpServerHandshake(string pidFilePath, string instanceToken) { _pidFileManager.StoreHandshake(pidFilePath, instanceToken); } private bool TryGetLocalHttpServerHandshake(out string pidFilePath, out string instanceToken) { return _pidFileManager.TryGetHandshake(out pidFilePath, out instanceToken); } private string GetLocalHttpServerPidFilePath(int port) { return _pidFileManager.GetPidFilePath(port); } private bool TryReadPidFromPidFile(string pidFilePath, out int pid) { return _pidFileManager.TryReadPid(pidFilePath, out pid); } private bool TryProcessCommandLineContainsInstanceToken(int pid, string instanceToken, out bool containsToken) { containsToken = false; if (pid <= 0 || string.IsNullOrEmpty(instanceToken)) { return false; } try { string tokenNeedle = instanceToken.ToLowerInvariant(); if (Application.platform == RuntimePlatform.WindowsEditor) { // Query full command line so we can validate token (reduces PID reuse risk). // Use CIM via PowerShell (wmic is deprecated). string ps = $"(Get-CimInstance Win32_Process -Filter \\\"ProcessId={pid}\\\").CommandLine"; bool ok = ExecPath.TryRun("powershell", $"-NoProfile -Command \"{ps}\"", Application.dataPath, out var stdout, out var stderr, 5000); string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant(); containsToken = combined.Contains(tokenNeedle); return ok; } if (TryGetUnixProcessArgs(pid, out var argsLowerNow)) { containsToken = argsLowerNow.Contains(NormalizeForMatch(tokenNeedle)); return true; } } catch { } return false; } private string ComputeShortHash(string input) { return _pidFileManager.ComputeShortHash(input); } private bool TryGetStoredLocalServerPid(int expectedPort, out int pid) { return _pidFileManager.TryGetStoredPid(expectedPort, out pid); } private string GetStoredArgsHash() { return _pidFileManager.GetStoredArgsHash(); } /// /// Clear the local uvx cache for the MCP server package /// /// True if successful, false otherwise public bool ClearUvxCache() { try { string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); string uvCommand = BuildUvPathFromUvx(uvxPath); // Get the package name string packageName = "mcp-for-unity"; // Run uvx cache clean command string args = $"cache clean {packageName}"; bool success; string stdout; string stderr; success = ExecuteUvCommand(uvCommand, args, out stdout, out stderr); if (success) { McpLog.Info($"uv cache cleared successfully: {stdout}"); return true; } string combinedOutput = string.Join( Environment.NewLine, new[] { stderr, stdout }.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim())); string lockHint = (!string.IsNullOrEmpty(combinedOutput) && combinedOutput.IndexOf("currently in-use", StringComparison.OrdinalIgnoreCase) >= 0) ? "Another uv process may be holding the cache lock; wait a moment and try again or clear with '--force' from a terminal." : string.Empty; if (string.IsNullOrEmpty(combinedOutput)) { combinedOutput = "Command failed with no output. Ensure uv is installed, on PATH, or set an override in Advanced Settings."; } McpLog.Error( $"Failed to clear uv cache using '{uvCommand} {args}'. " + $"Details: {combinedOutput}{(string.IsNullOrEmpty(lockHint) ? string.Empty : " Hint: " + lockHint)}"); return false; } catch (Exception ex) { McpLog.Error($"Error clearing uv cache: {ex.Message}"); return false; } } private bool ExecuteUvCommand(string uvCommand, string args, out string stdout, out string stderr) { stdout = null; stderr = null; string uvxPath = MCPServiceLocator.Paths.GetUvxPath(); string uvPath = BuildUvPathFromUvx(uvxPath); if (!string.Equals(uvCommand, uvPath, StringComparison.OrdinalIgnoreCase)) { return ExecPath.TryRun(uvCommand, args, Application.dataPath, out stdout, out stderr, 30000); } string command = $"{uvPath} {args}"; string extraPathPrepend = GetPlatformSpecificPathPrepend(); if (Application.platform == RuntimePlatform.WindowsEditor) { return ExecPath.TryRun("cmd.exe", $"/c {command}", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); } string shell = File.Exists("/bin/bash") ? "/bin/bash" : "/bin/sh"; if (!string.IsNullOrEmpty(shell) && File.Exists(shell)) { string escaped = command.Replace("\"", "\\\""); return ExecPath.TryRun(shell, $"-lc \"{escaped}\"", Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); } return ExecPath.TryRun(uvPath, args, Application.dataPath, out stdout, out stderr, 30000, extraPathPrepend); } private string BuildUvPathFromUvx(string uvxPath) { return _commandBuilder.BuildUvPathFromUvx(uvxPath); } private string GetPlatformSpecificPathPrepend() { return _commandBuilder.GetPlatformSpecificPathPrepend(); } /// /// Start the local HTTP server in a separate terminal window. /// Stops any existing server on the port and clears the uvx cache first. /// public bool StartLocalHttpServer() { /// Clean stale Python build artifacts when using a local dev server path AssetPathUtility.CleanLocalServerBuildArtifacts(); if (!TryGetLocalHttpServerCommandParts(out _, out _, out var displayCommand, out var error)) { EditorUtility.DisplayDialog( "Cannot Start HTTP Server", error ?? "The server command could not be constructed with the current settings.", "OK"); return false; } // First, try to stop any existing server (quietly; we'll only warn if the port remains occupied). StopLocalHttpServerInternal(quiet: true); // If the port is still occupied, don't start and explain why (avoid confusing "refusing to stop" warnings). try { string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) && uri.Port > 0) { var remaining = GetListeningProcessIdsForPort(uri.Port); if (remaining.Count > 0) { EditorUtility.DisplayDialog( "Port In Use", $"Cannot start the local HTTP server because port {uri.Port} is already in use by PID(s): " + $"{string.Join(", ", remaining)}\n\n" + "MCP For Unity will not terminate unrelated processes. Stop the owning process manually or change the HTTP URL.", "OK"); return false; } } } catch { } // Note: Dev mode cache-busting is handled by `uvx --no-cache --refresh` in the generated command. // Create a per-launch token + pidfile path so Stop can be deterministic without relying on port/PID heuristics. string baseUrlForPid = HttpEndpointUtility.GetLocalBaseUrl(); Uri.TryCreate(baseUrlForPid, UriKind.Absolute, out var uriForPid); int portForPid = uriForPid?.Port ?? 0; string instanceToken = Guid.NewGuid().ToString("N"); string pidFilePath = portForPid > 0 ? GetLocalHttpServerPidFilePath(portForPid) : null; string launchCommand = displayCommand; if (!string.IsNullOrEmpty(pidFilePath)) { launchCommand = $"{displayCommand} --pidfile {QuoteIfNeeded(pidFilePath)} --unity-instance-token {instanceToken}"; } if (EditorUtility.DisplayDialog( "Start Local HTTP Server", $"This will start the MCP server in HTTP mode in a new terminal window:\n\n{launchCommand}\n\n" + "Continue?", "Start Server", "Cancel")) { try { // Clear any stale handshake state from prior launches. ClearLocalServerPidTracking(); // Best-effort: delete stale pidfile if it exists. try { if (!string.IsNullOrEmpty(pidFilePath) && File.Exists(pidFilePath)) { DeletePidFile(pidFilePath); } } catch { } // Launch the server in a new terminal window (keeps user-visible logs). var startInfo = CreateTerminalProcessStartInfo(launchCommand); System.Diagnostics.Process.Start(startInfo); if (!string.IsNullOrEmpty(pidFilePath)) { StoreLocalHttpServerHandshake(pidFilePath, instanceToken); } McpLog.Info($"Started local HTTP server in terminal: {launchCommand}"); return true; } catch (Exception ex) { McpLog.Error($"Failed to start server: {ex.Message}"); EditorUtility.DisplayDialog( "Error", $"Failed to start server: {ex.Message}", "OK"); return false; } } return false; } /// /// Stop the local HTTP server by finding the process listening on the configured port /// public bool StopLocalHttpServer() { return StopLocalHttpServerInternal(quiet: false); } public bool StopManagedLocalHttpServer() { if (!TryGetLocalHttpServerHandshake(out var pidFilePath, out _)) { return false; } int port = 0; if (!TryGetPortFromPidFilePath(pidFilePath, out port) || port <= 0) { string baseUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (IsLocalUrl(baseUrl) && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) && uri.Port > 0) { port = uri.Port; } } if (port <= 0) { return false; } return StopLocalHttpServerInternal(quiet: true, portOverride: port, allowNonLocalUrl: true); } public bool IsLocalHttpServerRunning() { try { string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (!IsLocalUrl(httpUrl)) { return false; } if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) || uri.Port <= 0) { return false; } int port = uri.Port; // Handshake path: if we have a pidfile+token and the PID is still the listener, treat as running. if (TryGetLocalHttpServerHandshake(out var pidFilePath, out var instanceToken) && TryReadPidFromPidFile(pidFilePath, out var pidFromFile) && pidFromFile > 0) { var pidsNow = GetListeningProcessIdsForPort(port); if (pidsNow.Contains(pidFromFile)) { return true; } } var pids = GetListeningProcessIdsForPort(port); if (pids.Count == 0) { return false; } // Strong signal: stored PID is still the listener. if (TryGetStoredLocalServerPid(port, out int storedPid) && storedPid > 0) { if (pids.Contains(storedPid)) { return true; } } // Best-effort: if anything listening looks like our server, treat as running. foreach (var pid in pids) { if (pid <= 0) continue; if (LooksLikeMcpServerProcess(pid)) { return true; } } return false; } catch { return false; } } public bool IsLocalHttpServerReachable() { try { string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (!IsLocalUrl(httpUrl)) { return false; } if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) || uri.Port <= 0) { return false; } return TryConnectToLocalPort(uri.Host, uri.Port, timeoutMs: 50); } catch { return false; } } private static bool TryConnectToLocalPort(string host, int port, int timeoutMs) { try { if (string.IsNullOrEmpty(host)) { host = "127.0.0.1"; } var hosts = new HashSet(StringComparer.OrdinalIgnoreCase) { host }; if (host == "localhost" || host == "0.0.0.0") { hosts.Add("127.0.0.1"); } if (host == "::" || host == "0:0:0:0:0:0:0:0") { hosts.Add("::1"); } foreach (var target in hosts) { try { using (var client = new TcpClient()) { var connectTask = client.ConnectAsync(target, port); if (connectTask.Wait(timeoutMs) && client.Connected) { return true; } } } catch { // Ignore per-host failures. } } } catch { // Ignore probe failures and treat as unreachable. } return false; } private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, bool allowNonLocalUrl = false) { string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); if (!allowNonLocalUrl && !IsLocalUrl(httpUrl)) { if (!quiet) { McpLog.Warn("Cannot stop server: URL is not local."); } return false; } try { int port = 0; if (portOverride.HasValue) { port = portOverride.Value; } else { var uri = new Uri(httpUrl); port = uri.Port; } if (port <= 0) { if (!quiet) { McpLog.Warn("Cannot stop server: Invalid port."); } return false; } // Guardrails: // - Never terminate the Unity Editor process. // - Only terminate processes that look like the MCP server (uv/uvx/python running mcp-for-unity). // This prevents accidental termination of unrelated services (including Unity itself). int unityPid = GetCurrentProcessIdSafe(); bool stoppedAny = false; // Preferred deterministic stop path: if we have a pidfile+token from a Unity-managed launch, // validate and terminate exactly that PID. if (TryGetLocalHttpServerHandshake(out var pidFilePath, out var instanceToken)) { // Prefer deterministic stop when Unity started the server (pidfile+token). // If the pidfile isn't available yet (fast quit after start), we can optionally fall back // to port-based heuristics when a port override was supplied (managed-stop path). if (!TryReadPidFromPidFile(pidFilePath, out var pidFromFile) || pidFromFile <= 0) { if (!portOverride.HasValue) { if (!quiet) { McpLog.Warn( $"Cannot stop local HTTP server on port {port}: pidfile not available yet at '{pidFilePath}'. " + "If you just started the server, wait a moment and try again."); } return false; } // Managed-stop fallback: proceed with port-based heuristics below. // We intentionally do NOT clear handshake state here; it will be cleared if we successfully // stop a server process and/or the port is freed. } else { // Never kill Unity/Hub. if (unityPid > 0 && pidFromFile == unityPid) { if (!quiet) { McpLog.Warn($"Refusing to stop port {port}: pidfile PID {pidFromFile} is the Unity Editor process."); } } else { var listeners = GetListeningProcessIdsForPort(port); if (listeners.Count == 0) { // Nothing is listening anymore; clear stale handshake state. try { DeletePidFile(pidFilePath); } catch { } ClearLocalServerPidTracking(); if (!quiet) { McpLog.Info($"No process found listening on port {port}"); } return false; } bool pidIsListener = listeners.Contains(pidFromFile); bool tokenQueryOk = TryProcessCommandLineContainsInstanceToken(pidFromFile, instanceToken, out bool tokenMatches); bool allowKill; if (tokenQueryOk) { allowKill = tokenMatches; } else { // If token validation is unavailable (e.g. Windows CIM permission issues), // fall back to a stricter heuristic: only allow stop if the PID still looks like our server. allowKill = LooksLikeMcpServerProcess(pidFromFile); } if (pidIsListener && allowKill) { if (TerminateProcess(pidFromFile)) { stoppedAny = true; try { DeletePidFile(pidFilePath); } catch { } ClearLocalServerPidTracking(); if (!quiet) { McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pidFromFile})"); } return true; } if (!quiet) { McpLog.Warn($"Failed to terminate local HTTP server on port {port} (PID: {pidFromFile})."); } return false; } if (!quiet) { McpLog.Warn( $"Refusing to stop port {port}: pidfile PID {pidFromFile} failed validation " + $"(listener={pidIsListener}, tokenMatch={tokenMatches}, tokenQueryOk={tokenQueryOk})."); } return false; } } } var pids = GetListeningProcessIdsForPort(port); if (pids.Count == 0) { if (stoppedAny) { // We stopped what Unity started; the port is now free. if (!quiet) { McpLog.Info($"Stopped local HTTP server on port {port}"); } ClearLocalServerPidTracking(); return true; } if (!quiet) { McpLog.Info($"No process found listening on port {port}"); } ClearLocalServerPidTracking(); return false; } // Prefer killing the PID that we previously observed binding this port (if still valid). if (TryGetStoredLocalServerPid(port, out int storedPid)) { if (pids.Contains(storedPid)) { string expectedHash = string.Empty; expectedHash = GetStoredArgsHash(); // Prefer a fingerprint match (reduces PID reuse risk). If missing (older installs), // fall back to a looser check to avoid leaving orphaned servers after domain reload. if (TryGetUnixProcessArgs(storedPid, out var storedArgsLowerNow)) { // Never kill Unity/Hub. // Note: "mcp-for-unity" includes "unity", so detect MCP indicators first. bool storedMentionsMcp = storedArgsLowerNow.Contains("mcp-for-unity") || storedArgsLowerNow.Contains("mcp_for_unity") || storedArgsLowerNow.Contains("mcpforunity"); if (storedArgsLowerNow.Contains("unityhub") || storedArgsLowerNow.Contains("unity hub") || (storedArgsLowerNow.Contains("unity") && !storedMentionsMcp)) { if (!quiet) { McpLog.Warn($"Refusing to stop port {port}: stored PID {storedPid} appears to be a Unity process."); } } else { bool allowKill = false; if (!string.IsNullOrEmpty(expectedHash)) { allowKill = string.Equals(expectedHash, ComputeShortHash(storedArgsLowerNow), StringComparison.OrdinalIgnoreCase); } else { // Older versions didn't store a fingerprint; accept common server indicators. allowKill = storedArgsLowerNow.Contains("uvicorn") || storedArgsLowerNow.Contains("fastmcp") || storedArgsLowerNow.Contains("mcpforunity") || storedArgsLowerNow.Contains("mcp-for-unity") || storedArgsLowerNow.Contains("mcp_for_unity") || storedArgsLowerNow.Contains("uvx") || storedArgsLowerNow.Contains("python"); } if (allowKill && TerminateProcess(storedPid)) { if (!quiet) { McpLog.Info($"Stopped local HTTP server on port {port} (PID: {storedPid})"); } stoppedAny = true; ClearLocalServerPidTracking(); // Refresh the PID list to avoid double-work. pids = GetListeningProcessIdsForPort(port); } else if (!allowKill && !quiet) { McpLog.Warn($"Refusing to stop port {port}: stored PID {storedPid} did not match expected server fingerprint."); } } } } else { // Stale PID (no longer listening). Clear. ClearLocalServerPidTracking(); } } foreach (var pid in pids) { if (pid <= 0) continue; if (unityPid > 0 && pid == unityPid) { if (!quiet) { McpLog.Warn($"Refusing to stop port {port}: owning PID appears to be the Unity Editor process (PID {pid})."); } continue; } if (!LooksLikeMcpServerProcess(pid)) { if (!quiet) { McpLog.Warn($"Refusing to stop port {port}: owning PID {pid} does not look like mcp-for-unity."); } continue; } if (TerminateProcess(pid)) { McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pid})"); stoppedAny = true; } else { if (!quiet) { McpLog.Warn($"Failed to stop process PID {pid} on port {port}"); } } } if (stoppedAny) { ClearLocalServerPidTracking(); } return stoppedAny; } catch (Exception ex) { if (!quiet) { McpLog.Error($"Failed to stop server: {ex.Message}"); } return false; } } private bool TryGetUnixProcessArgs(int pid, out string argsLower) { return _processDetector.TryGetProcessCommandLine(pid, out argsLower); } private bool TryGetPortFromPidFilePath(string pidFilePath, out int port) { return _pidFileManager.TryGetPortFromPidFilePath(pidFilePath, out port); } private void DeletePidFile(string pidFilePath) { _pidFileManager.DeletePidFile(pidFilePath); } private List GetListeningProcessIdsForPort(int port) { return _processDetector.GetListeningProcessIdsForPort(port); } private int GetCurrentProcessIdSafe() { return _processDetector.GetCurrentProcessId(); } private bool LooksLikeMcpServerProcess(int pid) { return _processDetector.LooksLikeMcpServerProcess(pid); } private bool TerminateProcess(int pid) { return _processTerminator.Terminate(pid); } /// /// Attempts to build the command used for starting the local HTTP server /// public bool TryGetLocalHttpServerCommand(out string command, out string error) { command = null; error = null; if (!TryGetLocalHttpServerCommandParts(out var fileName, out var args, out var displayCommand, out error)) { return false; } // Maintain existing behavior: return a single command string suitable for display/copy. command = displayCommand; return true; } private bool TryGetLocalHttpServerCommandParts(out string fileName, out string arguments, out string displayCommand, out string error) { return _commandBuilder.TryBuildCommand(out fileName, out arguments, out displayCommand, out error); } /// /// Check if the configured HTTP URL is a local address /// public bool IsLocalUrl() { string httpUrl = HttpEndpointUtility.GetLocalBaseUrl(); return IsLocalUrl(httpUrl); } /// /// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0) /// private static bool IsLocalUrl(string url) { if (string.IsNullOrEmpty(url)) return false; try { var uri = new Uri(url); string host = uri.Host.ToLower(); return host == "localhost" || host == "127.0.0.1" || host == "0.0.0.0" || host == "::1"; } catch { return false; } } /// /// Check if the local HTTP server can be started /// public bool CanStartLocalServer() { bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport; return useHttpTransport && IsLocalUrl(); } private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command) { return _terminalLauncher.CreateTerminalProcessStartInfo(command); } } }