Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e515655
Fix test teardown to avoid dropping MCP bridge
dsarno Dec 26, 2025
ca4c830
Avoid leaking PlatformService in CodexConfigHelperTests
dsarno Dec 26, 2025
2e480c0
Fix SO MCP tooling: validate folder roots, normalize paths, expand te…
dsarno Dec 26, 2025
1e7abb3
Remove UnityMCPTests stress artifacts and ignore Assets/Temp
dsarno Dec 26, 2025
a117c64
Ignore UnityMCPTests Assets/Temp only
dsarno Dec 26, 2025
c8907ac
Clarify array_resize fallback logic comments
dsarno Dec 27, 2025
14ff22a
Refactor: simplify action set and reuse slash sanitization
dsarno Dec 27, 2025
6b76817
Enhance: preserve GUID on overwrite & support Vector/Color types in S…
dsarno Dec 27, 2025
39c73cb
Fix: ensure asset name matches filename to suppress Unity warnings
dsarno Dec 27, 2025
64a3bc6
Fix: resolve Unity warnings by ensuring asset name match and removing…
dsarno Dec 27, 2025
cc9b9a3
Refactor: Validate assetName, strict object parsing for vectors, remo…
dsarno Dec 27, 2025
553ef26
Hardening: reject Windows drive paths; clarify supported asset types
dsarno Dec 27, 2025
7bb6543
Delete FixscriptableobjecPlan.md
dsarno Dec 28, 2025
b20c3f0
Paginate get_hierarchy and get_components to prevent large payload cr…
dsarno Dec 28, 2025
dc3c72d
dev: add uvx dev-mode refresh + safer HTTP stop; fix server typing eval
dsarno Dec 28, 2025
4045d5a
Payload-safe paging defaults + docs; harden asset search; stabilize C…
dsarno Dec 28, 2025
b18cd93
chore: align uvx args + coercion helpers; tighten safety guidance
dsarno Dec 29, 2025
2482801
chore: minor cleanup + stabilize EditMode SO tests
dsarno Dec 29, 2025
9aa02d2
Merge upstream/main into improve-get-hierarchy-and-get-components
dsarno Dec 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,9 @@ private void Register()
else
{
var (uvxPath, gitUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" {packageName}";
bool devForceRefresh = GetDevModeForceRefresh();
string devFlags = devForceRefresh ? "--no-cache --refresh " : string.Empty;
args = $"mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" {packageName}";
}

string projectDir = Path.GetDirectoryName(Application.dataPath);
Expand Down Expand Up @@ -537,14 +539,23 @@ public override string GetManualSnippet()
}

string gitUrl = AssetPathUtility.GetMcpServerGitUrl();
bool devForceRefresh = GetDevModeForceRefresh();
string devFlags = devForceRefresh ? "--no-cache --refresh " : string.Empty;

return "# Register the MCP server with Claude Code:\n" +
$"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" --from \"{gitUrl}\" mcp-for-unity\n\n" +
$"claude mcp add --transport stdio UnityMCP -- \"{uvxPath}\" {devFlags}--from \"{gitUrl}\" mcp-for-unity\n\n" +
"# Unregister the MCP server:\n" +
"claude mcp remove UnityMCP\n\n" +
"# List registered servers:\n" +
"claude mcp list # Only works when claude is run in the project's directory";
}

private static bool GetDevModeForceRefresh()
{
try { return EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); }
catch { return false; }
}

public override IList<string> GetInstallationSteps() => new List<string>
{
"Ensure Claude CLI is installed",
Expand Down
1 change: 1 addition & 0 deletions MCPForUnity/Editor/Constants/EditorPrefKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal static class EditorPrefKeys
internal const string SessionId = "MCPForUnity.SessionId";
internal const string WebSocketUrlOverride = "MCPForUnity.WebSocketUrl";
internal const string GitUrlOverride = "MCPForUnity.GitUrlOverride";
internal const string DevModeForceServerRefresh = "MCPForUnity.DevModeForceServerRefresh";

internal const string PackageDeploySourcePath = "MCPForUnity.PackageDeploy.SourcePath";
internal const string PackageDeployLastBackupPath = "MCPForUnity.PackageDeploy.LastBackupPath";
Expand Down
28 changes: 28 additions & 0 deletions MCPForUnity/Editor/Helpers/CodexConfigHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Services;
using MCPForUnity.External.Tommy;
using UnityEditor;
using UnityEngine;

namespace MCPForUnity.Editor.Helpers
{
Expand All @@ -15,6 +17,26 @@ namespace MCPForUnity.Editor.Helpers
/// </summary>
public static class CodexConfigHelper
{
private static bool GetDevModeForceRefresh()
{
try
{
return EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
}
catch
{
return false;
}
}

private static void AddDevModeArgs(TomlArray args, bool devForceRefresh)
{
if (args == null) return;
if (!devForceRefresh) return;
args.Add(new TomlString { Value = "--no-cache" });
args.Add(new TomlString { Value = "--refresh" });
}

public static string BuildCodexServerBlock(string uvPath)
{
var table = new TomlTable();
Expand All @@ -37,9 +59,12 @@ public static string BuildCodexServerBlock(string uvPath)
{
// Stdio mode: Use command and args
var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
bool devForceRefresh = GetDevModeForceRefresh();

unityMCP["command"] = uvxPath;

var args = new TomlArray();
AddDevModeArgs(args, devForceRefresh);
if (!string.IsNullOrEmpty(fromUrl))
{
args.Add(new TomlString { Value = "--from" });
Expand Down Expand Up @@ -184,9 +209,12 @@ private static TomlTable CreateUnityMcpTable(string uvPath)
{
// Stdio mode: Use command and args
var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();
bool devForceRefresh = GetDevModeForceRefresh();

unityMCP["command"] = new TomlString { Value = uvxPath };

var argsArray = new TomlArray();
AddDevModeArgs(argsArray, devForceRefresh);
if (!string.IsNullOrEmpty(fromUrl))
{
argsArray.Add(new TomlString { Value = "--from" });
Expand Down
23 changes: 17 additions & 6 deletions MCPForUnity/Editor/Helpers/ConfigJsonBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ private static void PopulateUnityNode(JObject unity, string uvPath, McpClient cl
// Stdio mode: Use uvx command
var (uvxPath, fromUrl, packageName) = AssetPathUtility.GetUvxCommandParts();

var toolArgs = BuildUvxArgs(fromUrl, packageName);
bool devForceRefresh = false;
try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { }

var toolArgs = BuildUvxArgs(fromUrl, packageName, devForceRefresh);

if (ShouldUseWindowsCmdShim(client))
{
Expand Down Expand Up @@ -149,15 +152,23 @@ private static JObject EnsureObject(JObject parent, string name)
return created;
}

private static IList<string> BuildUvxArgs(string fromUrl, string packageName)
private static IList<string> BuildUvxArgs(string fromUrl, string packageName, bool devForceRefresh)
{
var args = new List<string> { packageName };

// Dev mode: force a fresh install/resolution (avoids stale cached builds while iterating).
// `--no-cache` is the key flag; `--refresh` ensures metadata is revalidated.
// Keep ordering consistent with other uvx builders: dev flags first, then --from <url>, then package name.
var args = new List<string>();
if (devForceRefresh)
{
args.Add("--no-cache");
args.Add("--refresh");
}
if (!string.IsNullOrEmpty(fromUrl))
{
args.Insert(0, fromUrl);
args.Insert(0, "--from");
args.Add("--from");
args.Add(fromUrl);
}
args.Add(packageName);

args.Add("--transport");
args.Add("stdio");
Expand Down
159 changes: 125 additions & 34 deletions MCPForUnity/Editor/Services/ServerManagementService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
Expand Down Expand Up @@ -171,15 +172,7 @@ public bool StartLocalHttpServer()
// First, try to stop any existing server
StopLocalHttpServer();

// Clear the cache to ensure we get a fresh version
try
{
ClearUvxCache();
}
catch (Exception ex)
{
McpLog.Warn($"Failed to clear cache before starting server: {ex.Message}");
}
// Note: Dev mode cache-busting is handled by `uvx --no-cache --refresh` in the generated command.

if (EditorUtility.DisplayDialog(
"Start Local HTTP Server",
Expand Down Expand Up @@ -237,20 +230,47 @@ public bool StopLocalHttpServer()
return false;
}

McpLog.Info($"Attempting to stop any process listening on local port {port}. This will terminate the owning process even if it is not the MCP server.");
// 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();

int pid = GetProcessIdForPort(port);
if (pid > 0)
{
KillProcess(pid);
McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pid})");
return true;
}
else
var pids = GetListeningProcessIdsForPort(port);
if (pids.Count == 0)
{
McpLog.Info($"No process found listening on port {port}");
return false;
}

bool stoppedAny = false;
foreach (var pid in pids)
{
if (pid <= 0) continue;
if (unityPid > 0 && pid == unityPid)
{
McpLog.Warn($"Refusing to stop port {port}: owning PID appears to be the Unity Editor process (PID {pid}).");
continue;
}

if (!LooksLikeMcpServerProcess(pid))
{
McpLog.Warn($"Refusing to stop port {port}: owning PID {pid} does not look like mcp-for-unity (uvx/uv/python).");
continue;
}

if (TerminateProcess(pid))
{
McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pid})");
stoppedAny = true;
}
else
{
McpLog.Warn($"Failed to stop process PID {pid} on port {port}");
}
}

return stoppedAny;
}
catch (Exception ex)
{
Expand All @@ -259,8 +279,9 @@ public bool StopLocalHttpServer()
}
}

private int GetProcessIdForPort(int port)
private List<int> GetListeningProcessIdsForPort(int port)
{
var results = new List<int>();
try
{
string stdout, stderr;
Expand All @@ -280,33 +301,29 @@ private int GetProcessIdForPort(int port)
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length > 0 && int.TryParse(parts[parts.Length - 1], out int pid))
{
return pid;
results.Add(pid);
}
}
}
}
}
else
{
// lsof -i :<port> -t
// 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 (!System.IO.File.Exists(lsofPath)) lsofPath = "lsof"; // Fallback

success = ExecPath.TryRun(lsofPath, $"-i :{port} -t", Application.dataPath, out stdout, out stderr);
// -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 pid))
{
if (pidStrings.Length > 1)
{
McpLog.Debug($"Multiple processes found on port {port}; attempting to stop PID {pid} returned by lsof -t.");
}

return pid;
results.Add(pid);
}
}
}
Expand All @@ -316,26 +333,96 @@ private int GetProcessIdForPort(int port)
{
McpLog.Warn($"Error checking port {port}: {ex.Message}");
}
return -1;
return results.Distinct().ToList();
}

private static int GetCurrentProcessIdSafe()
{
try { return System.Diagnostics.Process.GetCurrentProcess().Id; }
catch { return -1; }
}

private bool LooksLikeMcpServerProcess(int pid)
{
try
{
// Windows best-effort: tasklist /FI "PID eq X"
if (Application.platform == RuntimePlatform.WindowsEditor)
{
if (ExecPath.TryRun("cmd.exe", $"/c tasklist /FI \"PID eq {pid}\"", Application.dataPath, out var stdout, out var stderr, 5000))
{
string combined = (stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty);
combined = combined.ToLowerInvariant();
// Common process names: python.exe, uv.exe, uvx.exe
return combined.Contains("python") || combined.Contains("uvx") || combined.Contains("uv.exe") || combined.Contains("uvx.exe");
}
return false;
}

// macOS/Linux: ps -p pid -o comm= -o args=
if (ExecPath.TryRun("ps", $"-p {pid} -o comm= -o args=", Application.dataPath, out var psOut, out var psErr, 5000))
{
string s = (psOut ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(s))
{
s = (psErr ?? string.Empty).Trim().ToLowerInvariant();
}

// Explicitly never kill Unity / Unity Hub processes
if (s.Contains("unity") || s.Contains("unityhub") || s.Contains("unity hub"))
{
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 mentionsMcp = s.Contains("mcp-for-unity") || s.Contains("mcp_for_unity") || s.Contains("mcp for unity");
bool mentionsTransport = s.Contains("--transport") && s.Contains("http");

// Accept if it looks like uv/uvx/python launching our server package/entrypoint
if ((mentionsUvx || mentionsUv || mentionsPython) && (mentionsMcp || mentionsTransport))
{
return true;
}
}
}
catch { }

return false;
}

private void KillProcess(int pid)
private bool TerminateProcess(int pid)
{
try
{
string stdout, stderr;
if (Application.platform == RuntimePlatform.WindowsEditor)
{
ExecPath.TryRun("taskkill", $"/F /PID {pid}", Application.dataPath, out stdout, out stderr);
// taskkill without /F first; fall back to /F if needed.
bool ok = ExecPath.TryRun("taskkill", $"/PID {pid}", Application.dataPath, out stdout, out stderr);
if (!ok)
{
ok = ExecPath.TryRun("taskkill", $"/F /PID {pid}", Application.dataPath, out stdout, out stderr);
}
return ok;
}
else
{
ExecPath.TryRun("kill", $"-9 {pid}", Application.dataPath, out stdout, out stderr);
// Try a graceful termination first, then escalate.
bool ok = ExecPath.TryRun("kill", $"-15 {pid}", Application.dataPath, out stdout, out stderr);
if (!ok)
{
ok = ExecPath.TryRun("kill", $"-9 {pid}", Application.dataPath, out stdout, out stderr);
}
return ok;
}
}
catch (Exception ex)
{
McpLog.Error($"Error killing process {pid}: {ex.Message}");
return false;
}
}

Expand Down Expand Up @@ -368,9 +455,13 @@ public bool TryGetLocalHttpServerCommand(out string command, out string error)
return false;
}

bool devForceRefresh = false;
try { devForceRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false); } catch { }

string devFlags = devForceRefresh ? "--no-cache --refresh " : string.Empty;
string args = string.IsNullOrEmpty(fromUrl)
? $"{packageName} --transport http --http-url {httpUrl}"
: $"--from {fromUrl} {packageName} --transport http --http-url {httpUrl}";
? $"{devFlags}{packageName} --transport http --http-url {httpUrl}"
: $"{devFlags}--from {fromUrl} {packageName} --transport http --http-url {httpUrl}";

command = $"{uvxPath} {args}";
return true;
Expand Down
Loading
Loading