diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
index 6f7ca9f04..5f7a3bb9d 100644
--- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
+++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs
@@ -8,12 +8,14 @@ internal static class EditorPrefKeys
{
internal const string UseHttpTransport = "MCPForUnity.UseHttpTransport";
internal const string HttpTransportScope = "MCPForUnity.HttpTransportScope"; // "local" | "remote"
- internal const string LastLocalHttpServerPid = "MCPForUnity.LocalHttpServer.LastPid";
- internal const string LastLocalHttpServerPort = "MCPForUnity.LocalHttpServer.LastPort";
- internal const string LastLocalHttpServerStartedUtc = "MCPForUnity.LocalHttpServer.LastStartedUtc";
- internal const string LastLocalHttpServerPidArgsHash = "MCPForUnity.LocalHttpServer.LastPidArgsHash";
- internal const string LastLocalHttpServerPidFilePath = "MCPForUnity.LocalHttpServer.LastPidFilePath";
- internal const string LastLocalHttpServerInstanceToken = "MCPForUnity.LocalHttpServer.LastInstanceToken";
+ // Unity-managed local HTTP server state. EditorPrefs are shared by every editor the user runs,
+ // so PidFileManager appends ".{projectHash}.{port}" to each of these: two editors, or two ports,
+ // never read or clear each other's slot.
+ internal const string LocalHttpServerPid = "MCPForUnity.LocalHttpServer.Pid";
+ internal const string LocalHttpServerStartedUtc = "MCPForUnity.LocalHttpServer.StartedUtc";
+ internal const string LocalHttpServerPidArgsHash = "MCPForUnity.LocalHttpServer.PidArgsHash";
+ internal const string LocalHttpServerPidFilePath = "MCPForUnity.LocalHttpServer.PidFilePath";
+ internal const string LocalHttpServerInstanceToken = "MCPForUnity.LocalHttpServer.InstanceToken";
internal const string DebugLogs = "MCPForUnity.DebugLogs";
internal const string ValidationLevel = "MCPForUnity.ValidationLevel";
internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort";
diff --git a/MCPForUnity/Editor/Services/IServerManagementService.cs b/MCPForUnity/Editor/Services/IServerManagementService.cs
index 60f90b04d..4581371b7 100644
--- a/MCPForUnity/Editor/Services/IServerManagementService.cs
+++ b/MCPForUnity/Editor/Services/IServerManagementService.cs
@@ -49,11 +49,25 @@ public interface IServerManagementService
bool StopLocalHttpServer();
///
- /// Stop the Unity-managed local HTTP server if a handshake/pidfile exists,
- /// even if the current transport selection has changed.
+ /// Port of the local HTTP server this editor process launched, from the SessionState launch
+ /// marker. False for servers launched by another editor, by an earlier run of this editor,
+ /// or externally.
+ ///
+ bool TryGetLaunchedLocalHttpServerPort(out int port);
+
+ ///
+ /// Stop the local HTTP server this editor process launched, even if the current transport
+ /// selection has changed. No-op when this process did not launch one.
///
bool StopManagedLocalHttpServer();
+ ///
+ /// Asks the server on how many Unity instances other than this
+ /// project are connected (GET /api/instances). Bounded by ;
+ /// returns false when the server did not answer with a well-formed response in time.
+ ///
+ bool TryCountOtherConnectedUnityInstances(int port, int timeoutMs, out int otherInstances);
+
///
/// Best-effort detection: returns true if a local MCP HTTP server appears to be running
/// on the configured local URL/port (used to drive UI state even if the session is not active).
diff --git a/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs
index e757b86ce..f97ccbcfc 100644
--- a/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs
+++ b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs
@@ -10,12 +10,19 @@ namespace MCPForUnity.Editor.Services
///
/// Best-effort cleanup when the Unity Editor is quitting.
/// - Stops active transports so clients don't see a "hung" session longer than necessary.
- /// - Stops the local HTTP server this Unity instance launched (handshake/pidfile-based), so a
- /// headless server doesn't become an invisible orphan. This runs on quit only, never on domain reload.
+ /// - Stops the local HTTP server this editor process launched, but only when no other Unity
+ /// instance is still connected to it (last one out turns the lights off). A headless server
+ /// has no terminal window, so an unstopped one would be an invisible orphan; a stopped one
+ /// that other editors still use disconnects all of them. This runs on quit only, never on
+ /// domain reload.
///
[InitializeOnLoad]
internal static class McpEditorShutdownCleanup
{
+ // Upper bound for asking the server how many Unity instances are still connected. The quit
+ // handler already waits up to 750 ms on transport stops; keep the whole thing near a second.
+ internal const int InstanceProbeTimeoutMs = 500;
+
static McpEditorShutdownCleanup()
{
// Guard against duplicate subscriptions across domain reloads.
@@ -23,15 +30,23 @@ static McpEditorShutdownCleanup()
EditorApplication.quitting += OnEditorQuitting;
}
- // A -batchmode/CI instance resolves the interactive editor's server via the global
- // pidfile+port handshake, so cleanup there would stop another user's server. Mirror the
- // sibling guards (HttpAutoStartHandler, StdioBridgeHost): skip in batch unless opted in.
+ // A -batchmode/CI instance never auto-starts the server (HttpAutoStartHandler has the same
+ // guard), so it has nothing of its own to stop. Mirror the sibling guards (HttpAutoStartHandler,
+ // StdioBridgeHost): skip in batch unless opted in.
internal static bool ShouldRunCleanup() =>
ShouldRunCleanup(Application.isBatchMode, Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"));
internal static bool ShouldRunCleanup(bool isBatchMode, string allowBatchEnv) =>
!isBatchMode || !string.IsNullOrWhiteSpace(allowBatchEnv);
+ ///
+ /// Last-one-out decision for a server this editor process launched.
+ /// is null when the server did not answer the instance probe in time; that fails toward leaving
+ /// it running, because killing a server other editors depend on is worse than a stray process.
+ ///
+ internal static bool ShouldStopManagedServer(int? otherConnectedInstances) =>
+ otherConnectedInstances == 0;
+
private static void OnEditorQuitting()
{
if (!ShouldRunCleanup()) return;
@@ -52,14 +67,32 @@ private static void OnEditorQuitting()
McpLog.Warn($"Shutdown cleanup: failed to stop transports: {ex.Message}");
}
- // 2) Stop the local HTTP server this Unity instance launched (best-effort).
- // Headless servers have no terminal window, so an unstopped one is an invisible orphan.
- // StopManagedLocalHttpServer only stops the server matching our pidfile+instance-token handshake,
- // so it never touches servers launched by other Unity instances. This runs on quit only;
- // domain reloads must NOT stop the server (and don't — this handler is gated on EditorApplication.quitting).
+ // 2) Stop the local HTTP server this editor process launched (best-effort).
+ // The launch marker lives in SessionState, which is per editor process, so a server launched
+ // by another editor on this machine (or started externally) is never resolved here. Even for
+ // our own launch, other editors may share it: ask the server first and only stop it when we
+ // are the last Unity instance connected.
try
{
- MCPServiceLocator.Server.StopManagedLocalHttpServer();
+ var server = MCPServiceLocator.Server;
+ if (!server.TryGetLaunchedLocalHttpServerPort(out int port))
+ {
+ return;
+ }
+
+ int? otherInstances = server.TryCountOtherConnectedUnityInstances(port, InstanceProbeTimeoutMs, out int count)
+ ? count
+ : (int?)null;
+
+ if (!ShouldStopManagedServer(otherInstances))
+ {
+ McpLog.Debug(otherInstances.HasValue
+ ? $"Shutdown cleanup: leaving local HTTP server on port {port} running; {otherInstances.Value} other Unity instance(s) still connected."
+ : $"Shutdown cleanup: leaving local HTTP server on port {port} running; it did not report connected instances within {InstanceProbeTimeoutMs} ms.");
+ return;
+ }
+
+ server.StopManagedLocalHttpServer();
}
catch (Exception ex)
{
@@ -68,4 +101,3 @@ private static void OnEditorQuitting()
}
}
}
-
diff --git a/MCPForUnity/Editor/Services/Server/IPidFileManager.cs b/MCPForUnity/Editor/Services/Server/IPidFileManager.cs
index b9bd74be9..7be5234bf 100644
--- a/MCPForUnity/Editor/Services/Server/IPidFileManager.cs
+++ b/MCPForUnity/Editor/Services/Server/IPidFileManager.cs
@@ -2,7 +2,9 @@ namespace MCPForUnity.Editor.Services.Server
{
///
/// Interface for managing PID files and handshake state for the local HTTP server.
- /// Handles persistence of server process information across Unity domain reloads.
+ /// Handshake and tracking state is keyed per project and per port, and the launch
+ /// marker is per editor process, so concurrent editors sharing one server (or running
+ /// servers on different ports) never act on each other's state.
///
public interface IPidFileManager
{
@@ -27,14 +29,6 @@ public interface IPidFileManager
/// True if a valid PID was read
bool TryReadPid(string pidFilePath, out int pid);
- ///
- /// Attempts to extract the port number from a PID file path.
- ///
- /// Path to the PID file
- /// Output: the port number
- /// True if the port was extracted successfully
- bool TryGetPortFromPidFilePath(string pidFilePath, out int port);
-
///
/// Deletes a PID file.
///
@@ -42,22 +36,35 @@ public interface IPidFileManager
void DeletePidFile(string pidFilePath);
///
- /// Stores the handshake information (PID file path and instance token) in EditorPrefs.
+ /// Stores the handshake for a server this editor process just launched: the PID file path and
+ /// instance token go to EditorPrefs (per project, per port; survives editor restarts so the
+ /// server can still be stopped deterministically later), and the port is recorded in
+ /// SessionState as the launch marker (survives domain reloads, dies with this editor process).
///
+ /// Port the server was launched on
/// Path to the PID file
/// Unique instance token for the server
- void StoreHandshake(string pidFilePath, string instanceToken);
+ void StoreHandshake(int port, string pidFilePath, string instanceToken);
///
- /// Attempts to retrieve stored handshake information from EditorPrefs.
+ /// Attempts to retrieve the stored handshake for a port (this project's slot only).
///
+ /// Port to look up
/// Output: stored PID file path
/// Output: stored instance token
/// True if valid handshake information was found
- bool TryGetHandshake(out string pidFilePath, out string instanceToken);
+ bool TryGetHandshake(int port, out string pidFilePath, out string instanceToken);
///
- /// Stores PID tracking information in EditorPrefs.
+ /// Returns the port of the server this editor process launched, if any. False for servers
+ /// launched by other editors, by an earlier run of this editor, or externally.
+ ///
+ /// Output: the launched port
+ /// True if this editor process launched a server
+ bool TryGetLaunchedPort(out int port);
+
+ ///
+ /// Stores PID tracking information in EditorPrefs (per project, per port).
///
/// The process ID
/// The port number
@@ -74,15 +81,18 @@ public interface IPidFileManager
bool TryGetStoredPid(int expectedPort, out int pid);
///
- /// Gets the stored args hash for the tracked server.
+ /// Gets the stored args hash for the tracked server on a port.
///
+ /// The port number
/// The stored args hash, or empty string if not found
- string GetStoredArgsHash();
+ string GetStoredArgsHash(int port);
///
- /// Clears all PID tracking information from EditorPrefs.
+ /// Clears handshake and tracking information for a port, and the launch marker if it
+ /// points at that port. Other ports and other projects are untouched.
///
- void ClearTracking();
+ /// The port number
+ void ClearTracking(int port);
///
/// Computes a short hash of the input string for fingerprinting.
diff --git a/MCPForUnity/Editor/Services/Server/PidFileManager.cs b/MCPForUnity/Editor/Services/Server/PidFileManager.cs
index eca60ee27..7fc0b2dc0 100644
--- a/MCPForUnity/Editor/Services/Server/PidFileManager.cs
+++ b/MCPForUnity/Editor/Services/Server/PidFileManager.cs
@@ -5,6 +5,7 @@
using System.Security.Cryptography;
using System.Text;
using MCPForUnity.Editor.Constants;
+using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
@@ -12,10 +13,14 @@ namespace MCPForUnity.Editor.Services.Server
{
///
/// Manages PID files and handshake state for the local HTTP server.
- /// Handles persistence of server process information across Unity domain reloads.
+ /// EditorPrefs are per user, not per editor, so every key is suffixed with the project hash and
+ /// port; the "this editor process launched it" marker lives in SessionState, which survives
+ /// domain reloads and dies with the process.
///
public class PidFileManager : IPidFileManager
{
+ internal const string LaunchedPortSessionKey = "MCPForUnity.LocalHttpServer.LaunchedPort";
+
///
public string GetPidDirectory()
{
@@ -64,39 +69,6 @@ public bool TryReadPid(string pidFilePath, out int pid)
}
}
- ///
- 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;
- }
- }
-
///
public void DeletePidFile(string pidFilePath)
{
@@ -111,13 +83,18 @@ public void DeletePidFile(string pidFilePath)
}
///
- public void StoreHandshake(string pidFilePath, string instanceToken)
+ public void StoreHandshake(int port, string pidFilePath, string instanceToken)
{
+ if (port <= 0)
+ {
+ return;
+ }
+
try
{
if (!string.IsNullOrEmpty(pidFilePath))
{
- EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, pidFilePath);
+ EditorPrefs.SetString(Key(EditorPrefKeys.LocalHttpServerPidFilePath, port), pidFilePath);
}
}
catch { }
@@ -126,21 +103,28 @@ public void StoreHandshake(string pidFilePath, string instanceToken)
{
if (!string.IsNullOrEmpty(instanceToken))
{
- EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, instanceToken);
+ EditorPrefs.SetString(Key(EditorPrefKeys.LocalHttpServerInstanceToken, port), instanceToken);
}
}
catch { }
+
+ try { SessionState.SetInt(LaunchedPortSessionKey, port); } catch { }
}
///
- public bool TryGetHandshake(out string pidFilePath, out string instanceToken)
+ public bool TryGetHandshake(int port, out string pidFilePath, out string instanceToken)
{
pidFilePath = null;
instanceToken = null;
+ if (port <= 0)
+ {
+ return false;
+ }
+
try
{
- pidFilePath = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidFilePath, string.Empty);
- instanceToken = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerInstanceToken, string.Empty);
+ pidFilePath = EditorPrefs.GetString(Key(EditorPrefKeys.LocalHttpServerPidFilePath, port), string.Empty);
+ instanceToken = EditorPrefs.GetString(Key(EditorPrefKeys.LocalHttpServerInstanceToken, port), string.Empty);
if (string.IsNullOrEmpty(pidFilePath) || string.IsNullOrEmpty(instanceToken))
{
pidFilePath = null;
@@ -157,21 +141,33 @@ public bool TryGetHandshake(out string pidFilePath, out string instanceToken)
}
}
+ ///
+ public bool TryGetLaunchedPort(out int port)
+ {
+ port = 0;
+ try { port = SessionState.GetInt(LaunchedPortSessionKey, 0); } catch { port = 0; }
+ return port > 0;
+ }
+
///
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 { }
+ if (port <= 0)
+ {
+ return;
+ }
+
+ try { EditorPrefs.SetInt(Key(EditorPrefKeys.LocalHttpServerPid, port), pid); } catch { }
+ try { EditorPrefs.SetString(Key(EditorPrefKeys.LocalHttpServerStartedUtc, port), DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)); } catch { }
try
{
if (!string.IsNullOrEmpty(argsHash))
{
- EditorPrefs.SetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, argsHash);
+ EditorPrefs.SetString(Key(EditorPrefKeys.LocalHttpServerPidArgsHash, port), argsHash);
}
else
{
- EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash);
+ EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerPidArgsHash, port));
}
}
catch { }
@@ -181,13 +177,17 @@ public void StoreTracking(int pid, int port, string argsHash = null)
public bool TryGetStoredPid(int expectedPort, out int pid)
{
pid = 0;
+ if (expectedPort <= 0)
+ {
+ return false;
+ }
+
try
{
- int storedPid = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPid, 0);
- int storedPort = EditorPrefs.GetInt(EditorPrefKeys.LastLocalHttpServerPort, 0);
- string storedUtc = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerStartedUtc, string.Empty);
+ int storedPid = EditorPrefs.GetInt(Key(EditorPrefKeys.LocalHttpServerPid, expectedPort), 0);
+ string storedUtc = EditorPrefs.GetString(Key(EditorPrefKeys.LocalHttpServerStartedUtc, expectedPort), string.Empty);
- if (storedPid <= 0 || storedPort != expectedPort)
+ if (storedPid <= 0)
{
return false;
}
@@ -213,11 +213,11 @@ public bool TryGetStoredPid(int expectedPort, out int pid)
}
///
- public string GetStoredArgsHash()
+ public string GetStoredArgsHash(int port)
{
try
{
- return EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, string.Empty);
+ return EditorPrefs.GetString(Key(EditorPrefKeys.LocalHttpServerPidArgsHash, port), string.Empty);
}
catch
{
@@ -226,14 +226,31 @@ public string GetStoredArgsHash()
}
///
- public void ClearTracking()
+ public void ClearTracking(int port)
+ {
+ if (port <= 0)
+ {
+ return;
+ }
+
+ try { EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerPid, port)); } catch { }
+ try { EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerStartedUtc, port)); } catch { }
+ try { EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerPidArgsHash, port)); } catch { }
+ try { EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerPidFilePath, port)); } catch { }
+ try { EditorPrefs.DeleteKey(Key(EditorPrefKeys.LocalHttpServerInstanceToken, port)); } catch { }
+ try
+ {
+ if (SessionState.GetInt(LaunchedPortSessionKey, 0) == port)
+ {
+ SessionState.EraseInt(LaunchedPortSessionKey);
+ }
+ }
+ catch { }
+ }
+
+ private static string Key(string baseKey, int port)
{
- 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 { }
+ return $"{baseKey}.{ProjectIdentityUtility.GetProjectHash()}.{port}";
}
///
diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs
index 7a2c546cc..8ea74f2b3 100644
--- a/MCPForUnity/Editor/Services/ServerManagementService.cs
+++ b/MCPForUnity/Editor/Services/ServerManagementService.cs
@@ -61,19 +61,19 @@ private string NormalizeForMatch(string s)
return _processDetector.NormalizeForMatch(s);
}
- private void ClearLocalServerPidTracking()
+ private void ClearLocalServerPidTracking(int port)
{
- _pidFileManager.ClearTracking();
+ _pidFileManager.ClearTracking(port);
}
- private void StoreLocalHttpServerHandshake(string pidFilePath, string instanceToken)
+ private void StoreLocalHttpServerHandshake(int port, string pidFilePath, string instanceToken)
{
- _pidFileManager.StoreHandshake(pidFilePath, instanceToken);
+ _pidFileManager.StoreHandshake(port, pidFilePath, instanceToken);
}
- private bool TryGetLocalHttpServerHandshake(out string pidFilePath, out string instanceToken)
+ private bool TryGetLocalHttpServerHandshake(int port, out string pidFilePath, out string instanceToken)
{
- return _pidFileManager.TryGetHandshake(out pidFilePath, out instanceToken);
+ return _pidFileManager.TryGetHandshake(port, out pidFilePath, out instanceToken);
}
private string GetLocalHttpServerPidFilePath(int port)
@@ -130,9 +130,9 @@ private bool TryGetStoredLocalServerPid(int expectedPort, out int pid)
return _pidFileManager.TryGetStoredPid(expectedPort, out pid);
}
- private string GetStoredArgsHash()
+ private string GetStoredArgsHash(int port)
{
- return _pidFileManager.GetStoredArgsHash();
+ return _pidFileManager.GetStoredArgsHash(port);
}
///
@@ -315,8 +315,11 @@ public bool StartLocalHttpServer(bool quiet = false)
try
{
- // Clear any stale handshake state from prior launches.
- ClearLocalServerPidTracking();
+ // Clear any stale handshake state from prior launches on this port.
+ if (portForPid > 0)
+ {
+ ClearLocalServerPidTracking(portForPid);
+ }
_lastLaunchedProcess = null;
// Best-effort: delete stale pidfile if it exists.
@@ -362,7 +365,7 @@ public bool StartLocalHttpServer(bool quiet = false)
_lastLaunchedProcess = System.Diagnostics.Process.Start(startInfo);
if (!string.IsNullOrEmpty(pidFilePath))
{
- StoreLocalHttpServerHandshake(pidFilePath, instanceToken);
+ StoreLocalHttpServerHandshake(portForPid, pidFilePath, instanceToken);
}
return true;
}
@@ -388,31 +391,129 @@ public bool StopLocalHttpServer()
return StopLocalHttpServerInternal(quiet: false);
}
+ public bool TryGetLaunchedLocalHttpServerPort(out int port)
+ {
+ return _pidFileManager.TryGetLaunchedPort(out port);
+ }
+
public bool StopManagedLocalHttpServer()
{
- if (!TryGetLocalHttpServerHandshake(out var pidFilePath, out _))
+ // The SessionState launch marker is per editor process, so a server launched by another
+ // editor (or externally) has no marker here and is left alone.
+ if (!TryGetLaunchedLocalHttpServerPort(out int port))
{
return false;
}
- int port = 0;
- if (!TryGetPortFromPidFilePath(pidFilePath, out port) || port <= 0)
+ return StopLocalHttpServerInternal(quiet: true, portOverride: port, allowNonLocalUrl: true);
+ }
+
+ public bool TryCountOtherConnectedUnityInstances(int port, int timeoutMs, out int otherInstances)
+ {
+ otherInstances = 0;
+ if (port <= 0)
+ {
+ return false;
+ }
+
+ try
{
string baseUrl = HttpEndpointUtility.GetLocalBaseUrl();
- if (IsLocalUrl(baseUrl)
- && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)
- && uri.Port > 0)
+ if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri))
{
- port = uri.Port;
+ return false;
+ }
+
+ string ownProjectHash = ProjectIdentityUtility.GetProjectHash();
+
+ // Same shared budget across candidate hosts as TryConnectToLocalPort: this runs on quit.
+ var elapsed = System.Diagnostics.Stopwatch.StartNew();
+ foreach (string host in BuildLocalProbeHosts(uri.Host))
+ {
+ int remainingMs = timeoutMs - (int)elapsed.ElapsedMilliseconds;
+ if (remainingMs <= 0)
+ {
+ break;
+ }
+
+ string url = new UriBuilder(uri.Scheme, host, port, "/api/instances").Uri.ToString();
+ string json = TryHttpGet(url, remainingMs);
+ if (json != null)
+ {
+ return TryCountOtherInstances(json, ownProjectHash, out otherInstances);
+ }
}
}
+ catch { }
- if (port <= 0)
+ return false;
+ }
+
+ ///
+ /// Counts the instances in a GET /api/instances response whose project hash is not ours.
+ /// Our own session may still be listed while the hub processes our WebSocket close, so it is
+ /// filtered by hash rather than assumed gone. Returns false for anything that is not a
+ /// well-formed success response, so callers fail toward leaving the server running.
+ ///
+ internal static bool TryCountOtherInstances(string json, string ownProjectHash, out int otherInstances)
+ {
+ otherInstances = 0;
+ try
{
+ var root = Newtonsoft.Json.Linq.JObject.Parse(json);
+ if (root.Value("success") != true)
+ {
+ return false;
+ }
+
+ if (!(root["instances"] is Newtonsoft.Json.Linq.JArray instances))
+ {
+ return false;
+ }
+
+ foreach (var instance in instances)
+ {
+ string hash = (instance as Newtonsoft.Json.Linq.JObject)?.Value("hash");
+ if (!string.Equals(hash, ownProjectHash, StringComparison.OrdinalIgnoreCase))
+ {
+ otherInstances++;
+ }
+ }
+
+ return true;
+ }
+ catch
+ {
+ otherInstances = 0;
return false;
}
+ }
- return StopLocalHttpServerInternal(quiet: true, portOverride: port, allowNonLocalUrl: true);
+ private static string TryHttpGet(string url, int timeoutMs)
+ {
+ try
+ {
+ using (var client = new System.Net.Http.HttpClient())
+ {
+ client.Timeout = TimeSpan.FromMilliseconds(timeoutMs);
+ var task = System.Threading.Tasks.Task.Run(async () =>
+ {
+ using (var response = await client.GetAsync(url).ConfigureAwait(false))
+ {
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+ return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ }
+ });
+ return task.Wait(timeoutMs) ? task.Result : null;
+ }
+ }
+ catch
+ {
+ return null;
+ }
}
public bool IsLocalHttpServerRunning()
@@ -433,7 +534,7 @@ public bool IsLocalHttpServerRunning()
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)
+ if (TryGetLocalHttpServerHandshake(port, out var pidFilePath, out var instanceToken)
&& TryReadPidFromPidFile(pidFilePath, out var pidFromFile)
&& pidFromFile > 0)
{
@@ -636,7 +737,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b
// 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))
+ if (TryGetLocalHttpServerHandshake(port, 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
@@ -675,7 +776,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b
{
// Nothing is listening anymore; clear stale handshake state.
try { DeletePidFile(pidFilePath); } catch { }
- ClearLocalServerPidTracking();
+ ClearLocalServerPidTracking(port);
if (!quiet)
{
McpLog.Info($"No process found listening on port {port}");
@@ -702,7 +803,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b
{
stoppedAny = true;
try { DeletePidFile(pidFilePath); } catch { }
- ClearLocalServerPidTracking();
+ ClearLocalServerPidTracking(port);
if (!quiet)
{
McpLog.Info($"Stopped local HTTP server on port {port} (PID: {pidFromFile})");
@@ -727,7 +828,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b
$"(tokenMatch={tokenMatches}, tokenQueryOk={tokenQueryOk}). Falling back to guarded port heuristics.");
}
try { DeletePidFile(pidFilePath); } catch { }
- ClearLocalServerPidTracking();
+ ClearLocalServerPidTracking(port);
}
else
{
@@ -755,7 +856,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b
{
McpLog.Info($"Stopped local HTTP server on port {port}");
}
- ClearLocalServerPidTracking();
+ ClearLocalServerPidTracking(port);
return true;
}
@@ -763,7 +864,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b
{
McpLog.Info($"No process found listening on port {port}");
}
- ClearLocalServerPidTracking();
+ ClearLocalServerPidTracking(port);
return false;
}
@@ -773,7 +874,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b
if (pids.Contains(storedPid))
{
string expectedHash = string.Empty;
- expectedHash = GetStoredArgsHash();
+ expectedHash = GetStoredArgsHash(port);
// 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.
@@ -819,7 +920,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b
McpLog.Info($"Stopped local HTTP server on port {port} (PID: {storedPid})");
}
stoppedAny = true;
- ClearLocalServerPidTracking();
+ ClearLocalServerPidTracking(port);
// Refresh the PID list to avoid double-work.
pids = GetListeningProcessIdsForPort(port);
}
@@ -833,7 +934,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b
else
{
// Stale PID (no longer listening). Clear.
- ClearLocalServerPidTracking();
+ ClearLocalServerPidTracking(port);
}
}
@@ -874,7 +975,7 @@ private bool StopLocalHttpServerInternal(bool quiet, int? portOverride = null, b
if (stoppedAny)
{
- ClearLocalServerPidTracking();
+ ClearLocalServerPidTracking(port);
}
return stoppedAny;
}
@@ -893,11 +994,6 @@ 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);
diff --git a/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs b/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs
index 3d011362a..0ea0a1400 100644
--- a/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs
+++ b/MCPForUnity/Editor/Windows/EditorPrefs/EditorPrefsWindow.cs
@@ -50,8 +50,6 @@ public class EditorPrefsWindow : EditorWindow
{ EditorPrefKeys.ValidationLevel, EditorPrefType.Int },
{ EditorPrefKeys.LastUpdateCheck, EditorPrefType.String },
{ EditorPrefKeys.LastStdIoUpgradeVersion, EditorPrefType.Int },
- { EditorPrefKeys.LastLocalHttpServerPid, EditorPrefType.Int },
- { EditorPrefKeys.LastLocalHttpServerPort, EditorPrefType.Int },
// String prefs
{ EditorPrefKeys.EditorWindowActivePanel, EditorPrefType.String },
@@ -72,10 +70,6 @@ public class EditorPrefsWindow : EditorWindow
{ EditorPrefKeys.LatestKnownVersion, EditorPrefType.String },
{ EditorPrefKeys.LastAssetStoreUpdateCheck, EditorPrefType.String },
{ EditorPrefKeys.LatestKnownAssetStoreVersion, EditorPrefType.String },
- { EditorPrefKeys.LastLocalHttpServerStartedUtc, EditorPrefType.String },
- { EditorPrefKeys.LastLocalHttpServerPidArgsHash, EditorPrefType.String },
- { EditorPrefKeys.LastLocalHttpServerPidFilePath, EditorPrefType.String },
- { EditorPrefKeys.LastLocalHttpServerInstanceToken, EditorPrefType.String },
};
// Templates
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/McpEditorShutdownCleanupTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/McpEditorShutdownCleanupTests.cs
index 74bf851bd..0ded819b1 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/McpEditorShutdownCleanupTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/McpEditorShutdownCleanupTests.cs
@@ -45,5 +45,28 @@ public void ShouldRunCleanup_Parameterless_MatchesEnvironment()
|| !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"));
Assert.AreEqual(expected, McpEditorShutdownCleanup.ShouldRunCleanup());
}
+
+ [Test]
+ public void ShouldStopManagedServer_NoOtherInstances_Stops()
+ {
+ // Last one out: the launching editor is the only Unity instance left on the server.
+ Assert.IsTrue(McpEditorShutdownCleanup.ShouldStopManagedServer(otherConnectedInstances: 0));
+ }
+
+ [Test]
+ public void ShouldStopManagedServer_OtherInstancesConnected_LeavesServerRunning()
+ {
+ // Two editors share one HTTP-local server; the launcher quitting must not disconnect the other.
+ Assert.IsFalse(McpEditorShutdownCleanup.ShouldStopManagedServer(otherConnectedInstances: 1));
+ Assert.IsFalse(McpEditorShutdownCleanup.ShouldStopManagedServer(otherConnectedInstances: 3));
+ }
+
+ [Test]
+ public void ShouldStopManagedServer_ProbeFailed_LeavesServerRunning()
+ {
+ // null = the server did not answer within the quit-time budget. Fail toward not killing:
+ // a stray headless process is recoverable, a torn-down shared server is not.
+ Assert.IsFalse(McpEditorShutdownCleanup.ShouldStopManagedServer(otherConnectedInstances: null));
+ }
}
}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/PidFileManagerTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/PidFileManagerTests.cs
index 8b0d53508..99d3b898d 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/PidFileManagerTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/Server/PidFileManagerTests.cs
@@ -1,7 +1,6 @@
using System.IO;
using NUnit.Framework;
using MCPForUnity.Editor.Services.Server;
-using MCPForUnity.Editor.Constants;
using UnityEditor;
using UnityEngine;
@@ -13,15 +12,24 @@ namespace MCPForUnityTests.Editor.Services.Server
[TestFixture]
public class PidFileManagerTests
{
+ // Ports used by handshake/tracking tests. Kept away from the default 8080 so a server the
+ // host editor actually launched is never touched, and cleared per port because the slots
+ // are per project+port now.
+ private const int PortA = 58080;
+ private const int PortB = 58081;
+
private PidFileManager _manager;
private string _testPidFilePath;
+ private int _savedLaunchedPort;
[SetUp]
public void SetUp()
{
_manager = new PidFileManager();
- // Clear any test state
- ClearTestEditorPrefs();
+ // The launch marker is one SessionState slot per editor process; preserve whatever the
+ // host editor recorded so running these tests never drops its own server ownership.
+ _savedLaunchedPort = SessionState.GetInt(PidFileManager.LaunchedPortSessionKey, 0);
+ ClearTestState();
}
[TearDown]
@@ -32,18 +40,18 @@ public void TearDown()
{
try { File.Delete(_testPidFilePath); } catch { }
}
- // Clear test state
- ClearTestEditorPrefs();
+ ClearTestState();
+ if (_savedLaunchedPort > 0)
+ {
+ SessionState.SetInt(PidFileManager.LaunchedPortSessionKey, _savedLaunchedPort);
+ }
}
- private void ClearTestEditorPrefs()
+ private void ClearTestState()
{
- 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 { }
+ _manager.ClearTracking(PortA);
+ _manager.ClearTracking(PortB);
+ SessionState.EraseInt(PidFileManager.LaunchedPortSessionKey);
}
#region GetPidFilePath Tests
@@ -195,101 +203,119 @@ public void TryReadPid_NegativePid_ReturnsFalse()
#endregion
- #region TryGetPortFromPidFilePath Tests
+ #region Handshake Tests
[Test]
- public void TryGetPortFromPidFilePath_ValidPath_ReturnsTrue()
+ public void StoreHandshake_ValidData_StoresInEditorPrefs()
{
// Arrange
- string path = "/some/path/mcp_http_8080.pid";
+ string pidFilePath = "/test/path.pid";
+ string instanceToken = "test-token-123";
// Act
- bool result = _manager.TryGetPortFromPidFilePath(path, out int port);
+ _manager.StoreHandshake(PortA, pidFilePath, instanceToken);
+ bool result = _manager.TryGetHandshake(PortA, out var storedPath, out var storedToken);
// Assert
Assert.IsTrue(result);
- Assert.AreEqual(8080, port);
+ Assert.AreEqual(pidFilePath, storedPath);
+ Assert.AreEqual(instanceToken, storedToken);
}
[Test]
- public void TryGetPortFromPidFilePath_DifferentPort_ParsesCorrectly()
+ public void TryGetHandshake_NoHandshake_ReturnsFalse()
{
- // Arrange
- string path = "/path/to/mcp_http_9999.pid";
-
// Act
- bool result = _manager.TryGetPortFromPidFilePath(path, out int port);
+ bool result = _manager.TryGetHandshake(PortA, out var pidFilePath, out var instanceToken);
// Assert
- Assert.IsTrue(result);
- Assert.AreEqual(9999, port);
+ Assert.IsFalse(result);
+ Assert.IsNull(pidFilePath);
+ Assert.IsNull(instanceToken);
}
[Test]
- public void TryGetPortFromPidFilePath_NullPath_ReturnsFalse()
+ public void TryGetHandshake_OtherPort_ReturnsFalse()
{
- // Act
- bool result = _manager.TryGetPortFromPidFilePath(null, out int port);
+ // Two Unity-managed servers on different ports must not clobber each other's handshake.
+ _manager.StoreHandshake(PortA, "/a.pid", "token-a");
- // Assert
- Assert.IsFalse(result);
- Assert.AreEqual(0, port);
+ Assert.IsFalse(_manager.TryGetHandshake(PortB, out _, out _));
}
[Test]
- public void TryGetPortFromPidFilePath_InvalidPrefix_ReturnsFalse()
+ public void StoreHandshake_TwoPorts_KeepBothSlots()
{
- // Arrange
- string path = "/some/path/wrong_prefix_8080.pid";
+ _manager.StoreHandshake(PortA, "/a.pid", "token-a");
+ _manager.StoreHandshake(PortB, "/b.pid", "token-b");
- // Act
- bool result = _manager.TryGetPortFromPidFilePath(path, out int port);
+ Assert.IsTrue(_manager.TryGetHandshake(PortA, out var pathA, out var tokenA));
+ Assert.IsTrue(_manager.TryGetHandshake(PortB, out var pathB, out var tokenB));
+ Assert.AreEqual("/a.pid", pathA);
+ Assert.AreEqual("token-a", tokenA);
+ Assert.AreEqual("/b.pid", pathB);
+ Assert.AreEqual("token-b", tokenB);
+ }
- // Assert
- Assert.IsFalse(result);
+ [Test]
+ public void StoreHandshake_NullValues_DoesNotThrow()
+ {
+ // Act & Assert
+ Assert.DoesNotThrow(() =>
+ {
+ _manager.StoreHandshake(PortA, null, null);
+ });
+ }
+
+ [Test]
+ public void StoreHandshake_InvalidPort_StoresNothing()
+ {
+ _manager.StoreHandshake(0, "/a.pid", "token-a");
+
+ Assert.IsFalse(_manager.TryGetLaunchedPort(out _));
}
#endregion
- #region Handshake Tests
+ #region Launch Marker Tests
[Test]
- public void StoreHandshake_ValidData_StoresInEditorPrefs()
+ public void TryGetLaunchedPort_NothingLaunched_ReturnsFalse()
{
- // Arrange
- string pidFilePath = "/test/path.pid";
- string instanceToken = "test-token-123";
+ // Regression for the multi-editor shutdown bug: an editor that never launched the server
+ // (it only connected to one another editor started) must not resolve a launch to stop.
+ Assert.IsFalse(_manager.TryGetLaunchedPort(out int port));
+ Assert.AreEqual(0, port);
+ }
- // Act
- _manager.StoreHandshake(pidFilePath, instanceToken);
- bool result = _manager.TryGetHandshake(out var storedPath, out var storedToken);
+ [Test]
+ public void StoreHandshake_RecordsLaunchedPort()
+ {
+ _manager.StoreHandshake(PortA, "/a.pid", "token-a");
- // Assert
- Assert.IsTrue(result);
- Assert.AreEqual(pidFilePath, storedPath);
- Assert.AreEqual(instanceToken, storedToken);
+ Assert.IsTrue(_manager.TryGetLaunchedPort(out int port));
+ Assert.AreEqual(PortA, port);
}
[Test]
- public void TryGetHandshake_NoHandshake_ReturnsFalse()
+ public void ClearTracking_SamePort_ClearsLaunchedPort()
{
- // Act
- bool result = _manager.TryGetHandshake(out var pidFilePath, out var instanceToken);
+ _manager.StoreHandshake(PortA, "/a.pid", "token-a");
- // Assert
- Assert.IsFalse(result);
- Assert.IsNull(pidFilePath);
- Assert.IsNull(instanceToken);
+ _manager.ClearTracking(PortA);
+
+ Assert.IsFalse(_manager.TryGetLaunchedPort(out _));
}
[Test]
- public void StoreHandshake_NullValues_DoesNotThrow()
+ public void ClearTracking_OtherPort_KeepsLaunchedPort()
{
- // Act & Assert
- Assert.DoesNotThrow(() =>
- {
- _manager.StoreHandshake(null, null);
- });
+ _manager.StoreHandshake(PortA, "/a.pid", "token-a");
+
+ _manager.ClearTracking(PortB);
+
+ Assert.IsTrue(_manager.TryGetLaunchedPort(out int port));
+ Assert.AreEqual(PortA, port);
}
#endregion
@@ -301,11 +327,10 @@ public void StoreTracking_ValidData_CanBeRetrieved()
{
// Arrange
int pid = 12345;
- int port = 8080;
// Act
- _manager.StoreTracking(pid, port);
- bool result = _manager.TryGetStoredPid(port, out int storedPid);
+ _manager.StoreTracking(pid, PortA);
+ bool result = _manager.TryGetStoredPid(PortA, out int storedPid);
// Assert
Assert.IsTrue(result);
@@ -316,10 +341,10 @@ public void StoreTracking_ValidData_CanBeRetrieved()
public void TryGetStoredPid_WrongPort_ReturnsFalse()
{
// Arrange
- _manager.StoreTracking(12345, 8080);
+ _manager.StoreTracking(12345, PortA);
// Act
- bool result = _manager.TryGetStoredPid(9090, out int storedPid);
+ bool result = _manager.TryGetStoredPid(PortB, out int storedPid);
// Assert
Assert.IsFalse(result, "Should return false for wrong port");
@@ -329,7 +354,7 @@ public void TryGetStoredPid_WrongPort_ReturnsFalse()
public void TryGetStoredPid_NoTracking_ReturnsFalse()
{
// Act
- bool result = _manager.TryGetStoredPid(8080, out int storedPid);
+ bool result = _manager.TryGetStoredPid(PortA, out int storedPid);
// Assert
Assert.IsFalse(result);
@@ -337,30 +362,44 @@ public void TryGetStoredPid_NoTracking_ReturnsFalse()
}
[Test]
- public void ClearTracking_RemovesAllKeys()
+ public void ClearTracking_RemovesAllKeysForPort()
{
// Arrange
- _manager.StoreTracking(12345, 8080, "somehash");
- _manager.StoreHandshake("/path.pid", "token");
+ _manager.StoreTracking(12345, PortA, "somehash");
+ _manager.StoreHandshake(PortA, "/path.pid", "token");
// Act
- _manager.ClearTracking();
- bool hasTracking = _manager.TryGetStoredPid(8080, out _);
- bool hasHandshake = _manager.TryGetHandshake(out _, out _);
+ _manager.ClearTracking(PortA);
+ bool hasTracking = _manager.TryGetStoredPid(PortA, out _);
+ bool hasHandshake = _manager.TryGetHandshake(PortA, out _, out _);
// Assert
Assert.IsFalse(hasTracking);
Assert.IsFalse(hasHandshake);
+ Assert.AreEqual(string.Empty, _manager.GetStoredArgsHash(PortA));
+ }
+
+ [Test]
+ public void ClearTracking_OtherPort_LeavesSlotIntact()
+ {
+ _manager.StoreTracking(12345, PortA, "somehash");
+ _manager.StoreHandshake(PortA, "/path.pid", "token");
+
+ _manager.ClearTracking(PortB);
+
+ Assert.IsTrue(_manager.TryGetStoredPid(PortA, out int storedPid));
+ Assert.AreEqual(12345, storedPid);
+ Assert.IsTrue(_manager.TryGetHandshake(PortA, out _, out _));
}
[Test]
public void GetStoredArgsHash_WithHash_ReturnsHash()
{
// Arrange
- _manager.StoreTracking(12345, 8080, "testhash123");
+ _manager.StoreTracking(12345, PortA, "testhash123");
// Act
- string hash = _manager.GetStoredArgsHash();
+ string hash = _manager.GetStoredArgsHash(PortA);
// Assert
Assert.AreEqual("testhash123", hash);
@@ -370,7 +409,7 @@ public void GetStoredArgsHash_WithHash_ReturnsHash()
public void GetStoredArgsHash_NoHash_ReturnsEmpty()
{
// Act
- string hash = _manager.GetStoredArgsHash();
+ string hash = _manager.GetStoredArgsHash(PortA);
// Assert
Assert.AreEqual(string.Empty, hash);
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ServerManagementServiceInstanceProbeTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ServerManagementServiceInstanceProbeTests.cs
new file mode 100644
index 000000000..acac774c8
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Services/ServerManagementServiceInstanceProbeTests.cs
@@ -0,0 +1,88 @@
+using NUnit.Framework;
+using MCPForUnity.Editor.Services;
+
+namespace MCPForUnityTests.Editor.Services
+{
+ ///
+ /// Parsing of the GET /api/instances response the quit handler uses for its last-one-out check.
+ /// The transport itself is not exercised here; the decision on top of the count is covered by
+ /// McpEditorShutdownCleanupTests.ShouldStopManagedServer.
+ ///
+ [TestFixture]
+ public class ServerManagementServiceInstanceProbeTests
+ {
+ private const string OwnHash = "aaaa111122223333";
+
+ [Test]
+ public void TryCountOtherInstances_OnlyOurOwnSession_CountsZero()
+ {
+ // Our own session can still be listed while the hub processes the WebSocket close that
+ // the quit handler issued a moment earlier; it must not count as "someone else".
+ string json = "{\"success\": true, \"instances\": [" +
+ "{\"session_id\": \"s1\", \"project\": \"Ours\", \"hash\": \"" + OwnHash + "\", \"unity_version\": \"6000.0.1f1\", \"connected_at\": 1.0}" +
+ "]}";
+
+ Assert.IsTrue(ServerManagementService.TryCountOtherInstances(json, OwnHash, out int others));
+ Assert.AreEqual(0, others);
+ }
+
+ [Test]
+ public void TryCountOtherInstances_AnotherProjectConnected_CountsIt()
+ {
+ string json = "{\"success\": true, \"instances\": [" +
+ "{\"session_id\": \"s1\", \"project\": \"Ours\", \"hash\": \"" + OwnHash + "\"}," +
+ "{\"session_id\": \"s2\", \"project\": \"Theirs\", \"hash\": \"bbbb444455556666\"}" +
+ "]}";
+
+ Assert.IsTrue(ServerManagementService.TryCountOtherInstances(json, OwnHash, out int others));
+ Assert.AreEqual(1, others);
+ }
+
+ [Test]
+ public void TryCountOtherInstances_OwnHashCaseDiffers_StillFiltered()
+ {
+ string json = "{\"success\": true, \"instances\": [{\"hash\": \"" + OwnHash.ToUpperInvariant() + "\"}]}";
+
+ Assert.IsTrue(ServerManagementService.TryCountOtherInstances(json, OwnHash, out int others));
+ Assert.AreEqual(0, others);
+ }
+
+ [Test]
+ public void TryCountOtherInstances_NoInstances_CountsZero()
+ {
+ Assert.IsTrue(ServerManagementService.TryCountOtherInstances("{\"success\": true, \"instances\": []}", OwnHash, out int others));
+ Assert.AreEqual(0, others);
+ }
+
+ [Test]
+ public void TryCountOtherInstances_InstanceWithoutHash_CountsAsOther()
+ {
+ // Unknown identity is treated as another editor: the caller then leaves the server running.
+ string json = "{\"success\": true, \"instances\": [{\"session_id\": \"s9\"}]}";
+
+ Assert.IsTrue(ServerManagementService.TryCountOtherInstances(json, OwnHash, out int others));
+ Assert.AreEqual(1, others);
+ }
+
+ [Test]
+ public void TryCountOtherInstances_ServerReportedFailure_ReturnsFalse()
+ {
+ Assert.IsFalse(ServerManagementService.TryCountOtherInstances("{\"success\": false, \"error\": \"boom\"}", OwnHash, out _));
+ }
+
+ [Test]
+ public void TryCountOtherInstances_MissingInstancesArray_ReturnsFalse()
+ {
+ Assert.IsFalse(ServerManagementService.TryCountOtherInstances("{\"success\": true}", OwnHash, out _));
+ }
+
+ [Test]
+ public void TryCountOtherInstances_MalformedJson_ReturnsFalse()
+ {
+ Assert.IsFalse(ServerManagementService.TryCountOtherInstances("not json", OwnHash, out int others));
+ Assert.AreEqual(0, others);
+ Assert.IsFalse(ServerManagementService.TryCountOtherInstances(string.Empty, OwnHash, out _));
+ Assert.IsFalse(ServerManagementService.TryCountOtherInstances(null, OwnHash, out _));
+ }
+ }
+}