升级XR插件版本

This commit is contained in:
Sora丶kong
2026-03-02 17:56:21 +08:00
parent 8962657674
commit 60f512a9bc
1317 changed files with 110305 additions and 48249 deletions

View File

@@ -0,0 +1,94 @@
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for managing PID files and handshake state for the local HTTP server.
/// Handles persistence of server process information across Unity domain reloads.
/// </summary>
public interface IPidFileManager
{
/// <summary>
/// Gets the directory where PID files are stored.
/// </summary>
/// <returns>Path to the PID file directory</returns>
string GetPidDirectory();
/// <summary>
/// Gets the path to the PID file for a specific port.
/// </summary>
/// <param name="port">The port number</param>
/// <returns>Full path to the PID file</returns>
string GetPidFilePath(int port);
/// <summary>
/// Attempts to read the PID from a PID file.
/// </summary>
/// <param name="pidFilePath">Path to the PID file</param>
/// <param name="pid">Output: the process ID if found</param>
/// <returns>True if a valid PID was read</returns>
bool TryReadPid(string pidFilePath, out int pid);
/// <summary>
/// Attempts to extract the port number from a PID file path.
/// </summary>
/// <param name="pidFilePath">Path to the PID file</param>
/// <param name="port">Output: the port number</param>
/// <returns>True if the port was extracted successfully</returns>
bool TryGetPortFromPidFilePath(string pidFilePath, out int port);
/// <summary>
/// Deletes a PID file.
/// </summary>
/// <param name="pidFilePath">Path to the PID file to delete</param>
void DeletePidFile(string pidFilePath);
/// <summary>
/// Stores the handshake information (PID file path and instance token) in EditorPrefs.
/// </summary>
/// <param name="pidFilePath">Path to the PID file</param>
/// <param name="instanceToken">Unique instance token for the server</param>
void StoreHandshake(string pidFilePath, string instanceToken);
/// <summary>
/// Attempts to retrieve stored handshake information from EditorPrefs.
/// </summary>
/// <param name="pidFilePath">Output: stored PID file path</param>
/// <param name="instanceToken">Output: stored instance token</param>
/// <returns>True if valid handshake information was found</returns>
bool TryGetHandshake(out string pidFilePath, out string instanceToken);
/// <summary>
/// Stores PID tracking information in EditorPrefs.
/// </summary>
/// <param name="pid">The process ID</param>
/// <param name="port">The port number</param>
/// <param name="argsHash">Optional hash of the command arguments</param>
void StoreTracking(int pid, int port, string argsHash = null);
/// <summary>
/// Attempts to retrieve a stored PID for the expected port.
/// Validates that the stored information is still valid (within 6-hour window).
/// </summary>
/// <param name="expectedPort">The expected port number</param>
/// <param name="pid">Output: the stored process ID</param>
/// <returns>True if a valid stored PID was found</returns>
bool TryGetStoredPid(int expectedPort, out int pid);
/// <summary>
/// Gets the stored args hash for the tracked server.
/// </summary>
/// <returns>The stored args hash, or empty string if not found</returns>
string GetStoredArgsHash();
/// <summary>
/// Clears all PID tracking information from EditorPrefs.
/// </summary>
void ClearTracking();
/// <summary>
/// Computes a short hash of the input string for fingerprinting.
/// </summary>
/// <param name="input">The input string</param>
/// <returns>A short hash string (16 hex characters)</returns>
string ComputeShortHash(string input);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f4a4c5d093da74ce79fb29a0670a58a7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,55 @@
using System.Collections.Generic;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for platform-specific process inspection operations.
/// Provides methods to detect MCP server processes, query process command lines,
/// and find processes listening on specific ports.
/// </summary>
public interface IProcessDetector
{
/// <summary>
/// Determines if a process looks like an MCP server process based on its command line.
/// Checks for indicators like uvx, python, mcp-for-unity, uvicorn, etc.
/// </summary>
/// <param name="pid">The process ID to check</param>
/// <returns>True if the process appears to be an MCP server</returns>
bool LooksLikeMcpServerProcess(int pid);
/// <summary>
/// Attempts to get the command line arguments for a Unix process.
/// </summary>
/// <param name="pid">The process ID</param>
/// <param name="argsLower">Output: normalized (lowercase, whitespace removed) command line args</param>
/// <returns>True if the command line was retrieved successfully</returns>
bool TryGetProcessCommandLine(int pid, out string argsLower);
/// <summary>
/// Gets the process IDs of all processes listening on a specific TCP port.
/// </summary>
/// <param name="port">The port number to check</param>
/// <returns>List of process IDs listening on the port</returns>
List<int> GetListeningProcessIdsForPort(int port);
/// <summary>
/// Gets the current Unity Editor process ID safely.
/// </summary>
/// <returns>The current process ID, or -1 if it cannot be determined</returns>
int GetCurrentProcessId();
/// <summary>
/// Checks if a process exists on Unix systems.
/// </summary>
/// <param name="pid">The process ID to check</param>
/// <returns>True if the process exists</returns>
bool ProcessExists(int pid);
/// <summary>
/// Normalizes a string for matching by removing whitespace and converting to lowercase.
/// </summary>
/// <param name="input">The input string</param>
/// <returns>Normalized string for matching</returns>
string NormalizeForMatch(string input);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 25f32875fb87541b69ead19c08520836
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,18 @@
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for platform-specific process termination.
/// Provides methods to terminate processes gracefully or forcefully.
/// </summary>
public interface IProcessTerminator
{
/// <summary>
/// Terminates a process using platform-appropriate methods.
/// On Unix: Tries SIGTERM first with grace period, then SIGKILL.
/// On Windows: Tries taskkill, then taskkill /F.
/// </summary>
/// <param name="pid">The process ID to terminate</param>
/// <returns>True if the process was terminated successfully</returns>
bool Terminate(int pid);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6a55c18e08b534afa85654410da8a463
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,39 @@
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for building uvx/server command strings.
/// Handles platform-specific command construction for starting the MCP HTTP server.
/// </summary>
public interface IServerCommandBuilder
{
/// <summary>
/// Attempts to build the command parts for starting the local HTTP server.
/// </summary>
/// <param name="fileName">Output: the executable file name (e.g., uvx path)</param>
/// <param name="arguments">Output: the command arguments</param>
/// <param name="displayCommand">Output: the full command string for display</param>
/// <param name="error">Output: error message if the command cannot be built</param>
/// <returns>True if the command was built successfully</returns>
bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
/// <summary>
/// Builds the uv path from the uvx path by replacing uvx with uv.
/// </summary>
/// <param name="uvxPath">Path to uvx executable</param>
/// <returns>Path to uv executable</returns>
string BuildUvPathFromUvx(string uvxPath);
/// <summary>
/// Gets the platform-specific PATH prepend string for finding uv/uvx.
/// </summary>
/// <returns>Paths to prepend to PATH environment variable</returns>
string GetPlatformSpecificPathPrepend();
/// <summary>
/// Quotes a string if it contains spaces.
/// </summary>
/// <param name="input">The input string</param>
/// <returns>The string, wrapped in quotes if it contains spaces</returns>
string QuoteIfNeeded(string input);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 12e80005e3f5b45239c48db981675ccf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,25 @@
using System.Diagnostics;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Interface for launching commands in platform-specific terminal windows.
/// Supports macOS Terminal, Windows cmd, and Linux terminal emulators.
/// </summary>
public interface ITerminalLauncher
{
/// <summary>
/// Creates a ProcessStartInfo for opening a terminal window with the given command.
/// Works cross-platform: macOS, Windows, and Linux.
/// </summary>
/// <param name="command">The command to execute in the terminal</param>
/// <returns>A configured ProcessStartInfo for launching the terminal</returns>
ProcessStartInfo CreateTerminalProcessStartInfo(string command);
/// <summary>
/// Gets the project root path for storing terminal scripts.
/// </summary>
/// <returns>Path to the project root directory</returns>
string GetProjectRootPath();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a5990e868c0cd4999858ce1c1a2defed
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,275 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using MCPForUnity.Editor.Constants;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Manages PID files and handshake state for the local HTTP server.
/// Handles persistence of server process information across Unity domain reloads.
/// </summary>
public class PidFileManager : IPidFileManager
{
/// <inheritdoc/>
public string GetPidDirectory()
{
return Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "RunState");
}
/// <inheritdoc/>
public string GetPidFilePath(int port)
{
string dir = GetPidDirectory();
Directory.CreateDirectory(dir);
return Path.Combine(dir, $"mcp_http_{port}.pid");
}
/// <inheritdoc/>
public bool TryReadPid(string pidFilePath, out int pid)
{
pid = 0;
try
{
if (string.IsNullOrEmpty(pidFilePath) || !File.Exists(pidFilePath))
{
return false;
}
string text = File.ReadAllText(pidFilePath).Trim();
if (int.TryParse(text, out pid))
{
return pid > 0;
}
// Best-effort: tolerate accidental extra whitespace/newlines.
var firstLine = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
if (int.TryParse(firstLine, out pid))
{
return pid > 0;
}
pid = 0;
return false;
}
catch
{
pid = 0;
return false;
}
}
/// <inheritdoc/>
public bool TryGetPortFromPidFilePath(string pidFilePath, out int port)
{
port = 0;
if (string.IsNullOrEmpty(pidFilePath))
{
return false;
}
try
{
string fileName = Path.GetFileNameWithoutExtension(pidFilePath);
if (string.IsNullOrEmpty(fileName))
{
return false;
}
const string prefix = "mcp_http_";
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string portText = fileName.Substring(prefix.Length);
return int.TryParse(portText, out port) && port > 0;
}
catch
{
port = 0;
return false;
}
}
/// <inheritdoc/>
public void DeletePidFile(string pidFilePath)
{
try
{
if (!string.IsNullOrEmpty(pidFilePath) && File.Exists(pidFilePath))
{
File.Delete(pidFilePath);
}
}
catch { }
}
/// <inheritdoc/>
public void StoreHandshake(string pidFilePath, string instanceToken)
{
try
{
if (!string.IsNullOrEmpty(pidFilePath))
{
EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, pidFilePath);
}
}
catch { }
try
{
if (!string.IsNullOrEmpty(instanceToken))
{
EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, instanceToken);
}
}
catch { }
}
/// <inheritdoc/>
public bool TryGetHandshake(out string pidFilePath, out string instanceToken)
{
pidFilePath = null;
instanceToken = null;
try
{
pidFilePath = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, string.Empty);
instanceToken = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, string.Empty);
if (string.IsNullOrEmpty(pidFilePath) || string.IsNullOrEmpty(instanceToken))
{
pidFilePath = null;
instanceToken = null;
return false;
}
return true;
}
catch
{
pidFilePath = null;
instanceToken = null;
return false;
}
}
/// <inheritdoc/>
public void StoreTracking(int pid, int port, string argsHash = null)
{
try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPid, pid); } catch { }
try { EditorPrefs.SetInt(EditorPrefKeys.LastLocalHttpServerPort, port); } catch { }
try { EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); } catch { }
try
{
if (!string.IsNullOrEmpty(argsHash))
{
EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, argsHash);
}
else
{
EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash);
}
}
catch { }
}
/// <inheritdoc/>
public bool TryGetStoredPid(int expectedPort, out int pid)
{
pid = 0;
try
{
int storedPid = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPid, 0);
int storedPort = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPort, 0);
string storedUtc = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, string.Empty);
if (storedPid <= 0 || storedPort != expectedPort)
{
return false;
}
// Only trust the stored PID for a short window to avoid PID reuse issues.
// (We still verify the PID is listening on the expected port before killing.)
if (!string.IsNullOrEmpty(storedUtc)
&& DateTime.TryParse(storedUtc, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var startedAt))
{
if ((DateTime.UtcNow - startedAt) > TimeSpan.FromHours(6))
{
return false;
}
}
pid = storedPid;
return true;
}
catch
{
return false;
}
}
/// <inheritdoc/>
public string GetStoredArgsHash()
{
try
{
return EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, string.Empty);
}
catch
{
return string.Empty;
}
}
/// <inheritdoc/>
public void ClearTracking()
{
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPid); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPort); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerStartedUtc); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidFilePath); } catch { }
try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerInstanceToken); } catch { }
}
/// <inheritdoc/>
public string ComputeShortHash(string input)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
try
{
using var sha = SHA256.Create();
byte[] bytes = Encoding.UTF8.GetBytes(input);
byte[] hash = sha.ComputeHash(bytes);
// 8 bytes => 16 hex chars is plenty as a stable fingerprint for our purposes.
var sb = new StringBuilder(16);
for (int i = 0; i < 8 && i < hash.Length; i++)
{
sb.Append(hash[i].ToString("x2"));
}
return sb.ToString();
}
catch
{
return string.Empty;
}
}
private static string GetProjectRootPath()
{
try
{
// Application.dataPath is ".../<Project>/Assets"
return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
}
catch
{
return Application.dataPath;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 57875f281fda94a4ea17cb74d4b13378
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,268 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Platform-specific process inspection for detecting MCP server processes.
/// </summary>
public class ProcessDetector : IProcessDetector
{
/// <inheritdoc/>
public string NormalizeForMatch(string input)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
var sb = new StringBuilder(input.Length);
foreach (char c in input)
{
if (char.IsWhiteSpace(c)) continue;
sb.Append(char.ToLowerInvariant(c));
}
return sb.ToString();
}
/// <inheritdoc/>
public int GetCurrentProcessId()
{
try { return System.Diagnostics.Process.GetCurrentProcess().Id; }
catch { return -1; }
}
/// <inheritdoc/>
public bool ProcessExists(int pid)
{
try
{
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// On Windows, use tasklist to check if process exists
bool ok = ExecPath.TryRun("tasklist", $"/FI \"PID eq {pid}\"", Application.dataPath, out var stdout, out var stderr, 5000);
string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).ToLowerInvariant();
return ok && combined.Contains(pid.ToString());
}
// Unix: ps exits non-zero when PID is not found.
string psPath = "/bin/ps";
if (!File.Exists(psPath)) psPath = "ps";
ExecPath.TryRun(psPath, $"-p {pid} -o pid=", Application.dataPath, out var psStdout, out var psStderr, 2000);
string combined2 = ((psStdout ?? string.Empty) + "\n" + (psStderr ?? string.Empty)).Trim();
return !string.IsNullOrEmpty(combined2) && combined2.Any(char.IsDigit);
}
catch
{
return true; // Assume it exists if we cannot verify.
}
}
/// <inheritdoc/>
public bool TryGetProcessCommandLine(int pid, out string argsLower)
{
argsLower = string.Empty;
try
{
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// Windows: use wmic to get command line
ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000);
string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty));
if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.ToLowerInvariant().Contains("commandline="))
{
argsLower = NormalizeForMatch(wmicOut ?? string.Empty);
return true;
}
return false;
}
// Unix: ps -p pid -ww -o args=
string psPath = "/bin/ps";
if (!File.Exists(psPath)) psPath = "ps";
bool ok = ExecPath.TryRun(psPath, $"-p {pid} -ww -o args=", Application.dataPath, out var stdout, out var stderr, 5000);
if (!ok && string.IsNullOrWhiteSpace(stdout))
{
return false;
}
string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).Trim();
if (string.IsNullOrEmpty(combined)) return false;
// Normalize for matching to tolerate ps wrapping/newlines.
argsLower = NormalizeForMatch(combined);
return true;
}
catch
{
return false;
}
}
/// <inheritdoc/>
public List<int> GetListeningProcessIdsForPort(int port)
{
var results = new List<int>();
try
{
string stdout, stderr;
bool success;
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// Run netstat -ano directly (without findstr) and filter in C#.
// Using findstr in a pipe causes the entire command to return exit code 1 when no matches are found,
// which ExecPath.TryRun interprets as failure. Running netstat alone gives us exit code 0 on success.
success = ExecPath.TryRun("netstat.exe", "-ano", Application.dataPath, out stdout, out stderr);
// Process stdout regardless of success flag - netstat might still produce valid output
if (!string.IsNullOrEmpty(stdout))
{
string portSuffix = $":{port}";
var lines = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
// Windows netstat format: Proto Local Address Foreign Address State PID
// Example: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 12345
if (line.Contains("LISTENING") && line.Contains(portSuffix))
{
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
// Verify the local address column actually ends with :{port}
// parts[0] = Proto (TCP), parts[1] = Local Address, parts[2] = Foreign Address, parts[3] = State, parts[4] = PID
if (parts.Length >= 5)
{
string localAddr = parts[1];
if (localAddr.EndsWith(portSuffix) && int.TryParse(parts[parts.Length - 1], out int parsedPid))
{
results.Add(parsedPid);
}
}
}
}
}
}
else
{
// lsof: only return LISTENers (avoids capturing random clients)
// Use /usr/sbin/lsof directly as it might not be in PATH for Unity
string lsofPath = "/usr/sbin/lsof";
if (!File.Exists(lsofPath)) lsofPath = "lsof"; // Fallback
// -nP: avoid DNS/service name lookups; faster and less error-prone
success = ExecPath.TryRun(lsofPath, $"-nP -iTCP:{port} -sTCP:LISTEN -t", Application.dataPath, out stdout, out stderr);
if (success && !string.IsNullOrWhiteSpace(stdout))
{
var pidStrings = stdout.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var pidString in pidStrings)
{
if (int.TryParse(pidString.Trim(), out int parsedPid))
{
results.Add(parsedPid);
}
}
}
}
}
catch (Exception ex)
{
McpLog.Warn($"Error checking port {port}: {ex.Message}");
}
return results.Distinct().ToList();
}
/// <inheritdoc/>
public bool LooksLikeMcpServerProcess(int pid)
{
try
{
// Windows best-effort: First check process name with tasklist, then try to get command line with wmic
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// Step 1: Check if process name matches known server executables
ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var tasklistOut, out var tasklistErr, 5000);
string tasklistCombined = ((tasklistOut ?? string.Empty) + "\n" + (tasklistErr ?? string.Empty)).ToLowerInvariant();
// Check for common process names
bool isPythonOrUv = tasklistCombined.Contains("python") || tasklistCombined.Contains("uvx") || tasklistCombined.Contains("uv.exe");
if (!isPythonOrUv)
{
return false;
}
// Step 2: Try to get command line with wmic for better validation
ExecPath.TryRun("cmd.exe", $"/c wmic process where \"ProcessId={pid}\" get CommandLine /value", Application.dataPath, out var wmicOut, out var wmicErr, 5000);
string wmicCombined = ((wmicOut ?? string.Empty) + "\n" + (wmicErr ?? string.Empty)).ToLowerInvariant();
string wmicCompact = NormalizeForMatch(wmicOut ?? string.Empty);
// If we can see the command line, validate it's our server
if (!string.IsNullOrEmpty(wmicCombined) && wmicCombined.Contains("commandline="))
{
bool mentionsMcp = wmicCompact.Contains("mcp-for-unity")
|| wmicCompact.Contains("mcp_for_unity")
|| wmicCompact.Contains("mcpforunity")
|| wmicCompact.Contains("mcpforunityserver");
bool mentionsTransport = wmicCompact.Contains("--transporthttp") || (wmicCompact.Contains("--transport") && wmicCompact.Contains("http"));
bool mentionsUvicorn = wmicCombined.Contains("uvicorn");
if (mentionsMcp || mentionsTransport || mentionsUvicorn)
{
return true;
}
}
// Fall back to just checking for python/uv processes if wmic didn't give us details
// This is less precise but necessary for cases where wmic access is restricted
return isPythonOrUv;
}
// macOS/Linux: ps -p pid -ww -o comm= -o args=
// Use -ww to avoid truncating long command lines (important for reliably spotting 'mcp-for-unity').
// Use an absolute ps path to avoid relying on PATH inside the Unity Editor process.
string psPath = "/bin/ps";
if (!File.Exists(psPath)) psPath = "ps";
// Important: ExecPath.TryRun returns false when exit code != 0, but ps output can still be useful.
// Always parse stdout/stderr regardless of exit code to avoid false negatives.
ExecPath.TryRun(psPath, $"-p {pid} -ww -o comm= -o args=", Application.dataPath, out var psOut, out var psErr, 5000);
string raw = ((psOut ?? string.Empty) + "\n" + (psErr ?? string.Empty)).Trim();
string s = raw.ToLowerInvariant();
string sCompact = NormalizeForMatch(raw);
if (!string.IsNullOrEmpty(s))
{
bool mentionsMcp = sCompact.Contains("mcp-for-unity")
|| sCompact.Contains("mcp_for_unity")
|| sCompact.Contains("mcpforunity");
// If it explicitly mentions the server package/entrypoint, that is sufficient.
// Note: Check before Unity exclusion since "mcp-for-unity" contains "unity".
if (mentionsMcp)
{
return true;
}
// Explicitly never kill Unity / Unity Hub processes
// Note: explicit !mentionsMcp is defensive; we already return early for mentionsMcp above.
if (s.Contains("unityhub") || s.Contains("unity hub") || (s.Contains("unity") && !mentionsMcp))
{
return false;
}
// Positive indicators
bool mentionsUvx = s.Contains("uvx") || s.Contains(" uvx ");
bool mentionsUv = s.Contains("uv ") || s.Contains("/uv");
bool mentionsPython = s.Contains("python");
bool mentionsUvicorn = s.Contains("uvicorn");
bool mentionsTransport = sCompact.Contains("--transporthttp") || (sCompact.Contains("--transport") && sCompact.Contains("http"));
// Accept if it looks like uv/uvx/python launching our server package/entrypoint
if ((mentionsUvx || mentionsUv || mentionsPython || mentionsUvicorn) && mentionsTransport)
{
return true;
}
}
}
catch { }
return false;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4df6fa24a35d74d1cb9b67e40e50b45d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,89 @@
using System;
using System.IO;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Platform-specific process termination for stopping MCP server processes.
/// </summary>
public class ProcessTerminator : IProcessTerminator
{
private readonly IProcessDetector _processDetector;
/// <summary>
/// Creates a new ProcessTerminator with the specified process detector.
/// </summary>
/// <param name="processDetector">Process detector for checking process existence</param>
public ProcessTerminator(IProcessDetector processDetector)
{
_processDetector = processDetector ?? throw new ArgumentNullException(nameof(processDetector));
}
/// <inheritdoc/>
public bool Terminate(int pid)
{
// CRITICAL: Validate PID before any kill operation.
// On Unix, kill(-1) kills ALL processes the user can signal!
// On Unix, kill(0) signals all processes in the process group.
// PID 1 is init/launchd and must never be killed.
// Only positive PIDs > 1 are valid for targeted termination.
if (pid <= 1)
{
return false;
}
// Never kill the current Unity process
int currentPid = _processDetector.GetCurrentProcessId();
if (currentPid > 0 && pid == currentPid)
{
return false;
}
try
{
string stdout, stderr;
if (Application.platform == RuntimePlatform.WindowsEditor)
{
// taskkill without /F first; fall back to /F if needed.
bool ok = ExecPath.TryRun("taskkill", $"/PID {pid} /T", Application.dataPath, out stdout, out stderr);
if (!ok)
{
ok = ExecPath.TryRun("taskkill", $"/F /PID {pid} /T", Application.dataPath, out stdout, out stderr);
}
return ok;
}
else
{
// Try a graceful termination first, then escalate if the process is still alive.
// Note: `kill -15` can succeed (exit 0) even if the process takes time to exit,
// so we verify and only escalate when needed.
string killPath = "/bin/kill";
if (!File.Exists(killPath)) killPath = "kill";
ExecPath.TryRun(killPath, $"-15 {pid}", Application.dataPath, out stdout, out stderr);
// Wait briefly for graceful shutdown.
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(8);
while (DateTime.UtcNow < deadline)
{
if (!_processDetector.ProcessExists(pid))
{
return true;
}
System.Threading.Thread.Sleep(100);
}
// Escalate.
ExecPath.TryRun(killPath, $"-9 {pid}", Application.dataPath, out stdout, out stderr);
return !_processDetector.ProcessExists(pid);
}
}
catch (Exception ex)
{
McpLog.Error($"Error killing process {pid}: {ex.Message}");
return false;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 900df88b4d0844704af9cb47633d44a9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,151 @@
using System;
using System.IO;
using System.Linq;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using MCPForUnity.Editor.Services;
using UnityEditor;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Builds uvx/server command strings for starting the MCP HTTP server.
/// Handles platform-specific command construction.
/// </summary>
public class ServerCommandBuilder : IServerCommandBuilder
{
/// <inheritdoc/>
public bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error)
{
fileName = null;
arguments = null;
displayCommand = null;
error = null;
bool useHttpTransport = EditorConfigurationCache.Instance.UseHttpTransport;
if (!useHttpTransport)
{
error = "HTTP transport is disabled. Enable it in the MCP For Unity window first.";
return false;
}
string httpUrl = HttpEndpointUtility.GetLocalBaseUrl();
if (!IsLocalUrl(httpUrl))
{
error = $"The configured URL ({httpUrl}) is not a local address. Local server launch only works for localhost.";
return false;
}
var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
if (string.IsNullOrEmpty(uvxPath))
{
error = "uv is not installed or found in PATH. Install it or set an override in Advanced Settings.";
return false;
}
// Use central helper that checks both DevModeForceServerRefresh AND local path detection.
// Note: --reinstall is not supported by uvx, use --no-cache --refresh instead
string devFlags = AssetPathUtility.ShouldForceUvxRefresh() ? "--no-cache --refresh " : string.Empty;
bool projectScopedTools = EditorPrefs.GetBool(
EditorPrefKeys.ProjectScopedToolsLocalHttp,
true
);
string scopedFlag = projectScopedTools ? " --project-scoped-tools" : string.Empty;
// Use centralized helper for beta server / prerelease args
string fromArgs = AssetPathUtility.GetBetaServerFromArgs(quoteFromPath: true);
string args = string.IsNullOrEmpty(fromArgs)
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}{scopedFlag}"
: $"{devFlags}{fromArgs} {packageName} --transport http --http-url {httpUrl}{scopedFlag}";
fileName = uvxPath;
arguments = args;
displayCommand = $"{QuoteIfNeeded(uvxPath)} {args}";
return true;
}
/// <inheritdoc/>
public string BuildUvPathFromUvx(string uvxPath)
{
if (string.IsNullOrWhiteSpace(uvxPath))
{
return uvxPath;
}
string directory = Path.GetDirectoryName(uvxPath);
string extension = Path.GetExtension(uvxPath);
string uvFileName = "uv" + extension;
return string.IsNullOrEmpty(directory)
? uvFileName
: Path.Combine(directory, uvFileName);
}
/// <inheritdoc/>
public string GetPlatformSpecificPathPrepend()
{
if (Application.platform == RuntimePlatform.OSXEditor)
{
return string.Join(Path.PathSeparator.ToString(), new[]
{
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin"
});
}
if (Application.platform == RuntimePlatform.LinuxEditor)
{
return string.Join(Path.PathSeparator.ToString(), new[]
{
"/usr/local/bin",
"/usr/bin",
"/bin"
});
}
if (Application.platform == RuntimePlatform.WindowsEditor)
{
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
return string.Join(Path.PathSeparator.ToString(), new[]
{
!string.IsNullOrEmpty(localAppData) ? Path.Combine(localAppData, "Programs", "uv") : null,
!string.IsNullOrEmpty(programFiles) ? Path.Combine(programFiles, "uv") : null
}.Where(p => !string.IsNullOrEmpty(p)).ToArray());
}
return null;
}
/// <inheritdoc/>
public string QuoteIfNeeded(string input)
{
if (string.IsNullOrEmpty(input)) return input;
return input.IndexOf(' ') >= 0 ? $"\"{input}\"" : input;
}
/// <summary>
/// Check if a URL is local (localhost, 127.0.0.1, 0.0.0.0, ::1)
/// </summary>
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;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db917800a5c2948088ede8a5d230b56e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,143 @@
using System;
using System.IO;
using MCPForUnity.Editor.Helpers;
using UnityEngine;
namespace MCPForUnity.Editor.Services.Server
{
/// <summary>
/// Launches commands in platform-specific terminal windows.
/// Supports macOS Terminal, Windows cmd, and Linux terminal emulators.
/// </summary>
public class TerminalLauncher : ITerminalLauncher
{
/// <inheritdoc/>
public string GetProjectRootPath()
{
try
{
// Application.dataPath is ".../<Project>/Assets"
return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
}
catch
{
return Application.dataPath;
}
}
/// <inheritdoc/>
public System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(string command)
{
if (string.IsNullOrWhiteSpace(command))
throw new ArgumentException("Command cannot be empty", nameof(command));
command = command.Replace("\r", "").Replace("\n", "");
#if UNITY_EDITOR_OSX
// macOS: Avoid AppleScript (automation permission prompts). Use a .command script and open it.
string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts");
Directory.CreateDirectory(scriptsDir);
string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.command");
File.WriteAllText(
scriptPath,
"#!/bin/bash\n" +
"set -e\n" +
"clear\n" +
$"{command}\n");
ExecPath.TryRun("/bin/chmod", $"+x \"{scriptPath}\"", Application.dataPath, out _, out _, 3000);
return new System.Diagnostics.ProcessStartInfo
{
FileName = "/usr/bin/open",
Arguments = $"-a Terminal \"{scriptPath}\"",
UseShellExecute = false,
CreateNoWindow = true
};
#elif UNITY_EDITOR_WIN
// Windows: Avoid brittle nested-quote escaping by writing a .cmd script and starting it in a new window.
string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts");
Directory.CreateDirectory(scriptsDir);
string scriptPath = Path.Combine(scriptsDir, "mcp-terminal.cmd");
File.WriteAllText(
scriptPath,
"@echo off\r\n" +
"cls\r\n" +
command + "\r\n");
return new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c start \"MCP Server\" cmd.exe /k \"{scriptPath}\"",
UseShellExecute = false,
CreateNoWindow = true
};
#else
// Linux: Try common terminal emulators
// We use bash -c to execute the command, so we must properly quote/escape for bash
// Escape single quotes for the inner bash string
string escapedCommandLinux = command.Replace("'", "'\\''");
// Wrap the command in single quotes for bash -c
string script = $"'{escapedCommandLinux}; exec bash'";
// Escape double quotes for the outer Process argument string
string escapedScriptForArg = script.Replace("\"", "\\\"");
string bashCmdArgs = $"bash -c \"{escapedScriptForArg}\"";
string[] terminals = { "gnome-terminal", "xterm", "konsole", "xfce4-terminal" };
string terminalCmd = null;
foreach (var term in terminals)
{
try
{
var which = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "which",
Arguments = term,
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true
});
which.WaitForExit(5000); // Wait for up to 5 seconds, the command is typically instantaneous
if (which.ExitCode == 0)
{
terminalCmd = term;
break;
}
}
catch { }
}
if (terminalCmd == null)
{
terminalCmd = "xterm"; // Fallback
}
// Different terminals have different argument formats
string args;
if (terminalCmd == "gnome-terminal")
{
args = $"-- {bashCmdArgs}";
}
else if (terminalCmd == "konsole")
{
args = $"-e {bashCmdArgs}";
}
else if (terminalCmd == "xfce4-terminal")
{
// xfce4-terminal expects -e "command string" or -e command arg
args = $"--hold -e \"{bashCmdArgs.Replace("\"", "\\\"")}\"";
}
else // xterm and others
{
args = $"-hold -e {bashCmdArgs}";
}
return new System.Diagnostics.ProcessStartInfo
{
FileName = terminalCmd,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true
};
#endif
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d9693a18d706548b3aae28ea87f1ed08
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: