269 lines
13 KiB
C#
269 lines
13 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|