From 2958bfc1bf29f1d09a7ead28e65d064574cea5e3 Mon Sep 17 00:00:00 2001 From: dsarno Date: Mon, 29 Dec 2025 20:49:23 -0800 Subject: [PATCH 01/16] Avoid blocking Claude CLI status checks on focus --- .../ClientConfig/McpClientConfigSection.cs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index 781d1c0ed..d779f864b 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -293,7 +293,8 @@ public void RefreshSelectedClient() if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count) { var client = configurators[selectedClientIndex]; - RefreshClientStatus(client, forceImmediate: true); + bool forceImmediate = client is not ClaudeCliMcpConfigurator; + RefreshClientStatus(client, forceImmediate); UpdateManualConfiguration(); UpdateClaudeCliPathVisibility(); } @@ -318,14 +319,6 @@ private void RefreshClientStatus(IMcpClientConfigurator client, bool forceImmedi private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImmediate) { - if (forceImmediate) - { - MCPServiceLocator.Client.CheckClientStatus(client, attemptAutoRewrite: false); - lastStatusChecks[client] = DateTime.UtcNow; - ApplyStatusToUi(client); - return; - } - bool hasStatus = lastStatusChecks.ContainsKey(client); bool needsRefresh = !hasStatus || ShouldRefreshClient(client); @@ -338,7 +331,7 @@ private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImm ApplyStatusToUi(client); } - if (needsRefresh && !statusRefreshInFlight.Contains(client)) + if ((forceImmediate || needsRefresh) && !statusRefreshInFlight.Contains(client)) { statusRefreshInFlight.Add(client); ApplyStatusToUi(client, showChecking: true); From dec7be0cc925021965a87e6220d7fb2a7762c35b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 29 Dec 2025 17:07:44 -0800 Subject: [PATCH 02/16] Fix Claude Code registration to remove existing server before re-registering When registering with Claude Code, if a UnityMCP server already exists, remove it first before adding the new registration. This ensures the transport mode (HTTP vs stdio) is always updated to match the current UseHttpTransport EditorPref setting. Previously, if a stdio registration existed and the user tried to register with HTTP, the command would fail with 'already exists' and the old stdio configuration would remain unchanged. --- .../Clients/McpClientConfiguratorBase.cs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index eb4ba2b52..7d23e315c 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -452,25 +452,26 @@ private void Register() } catch { } - bool already = false; - if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) + // Check if UnityMCP already exists and remove it first to ensure clean registration + // This ensures we always use the current transport mode setting + bool serverExists = ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out _, out _, 7000, pathPrepend); + if (serverExists) { - string combined = ($"{stdout}\n{stderr}") ?? string.Empty; - if (combined.IndexOf("already exists", StringComparison.OrdinalIgnoreCase) >= 0) + McpLog.Info("Existing UnityMCP registration found - removing to ensure transport mode is up-to-date"); + if (!ExecPath.TryRun(claudePath, "mcp remove UnityMCP", projectDir, out var removeStdout, out var removeStderr, 10000, pathPrepend)) { - already = true; - } - else - { - throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); + McpLog.Warn($"Failed to remove existing UnityMCP registration: {removeStderr}. Attempting to register anyway..."); } } - if (!already) + // Now add the registration with the current transport mode + if (!ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out var stderr, 15000, pathPrepend)) { - McpLog.Info("Successfully registered with Claude Code."); + throw new InvalidOperationException($"Failed to register with Claude Code:\n{stderr}\n{stdout}"); } + McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport."); + CheckStatus(); } From 6aa42386e75094a8e9e9e5c74e69905caa6c168a Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 29 Dec 2025 19:47:51 -0800 Subject: [PATCH 03/16] Fix Claude Code transport validation to parse CLI output format correctly The validation code was incorrectly parsing the output of 'claude mcp get UnityMCP' by looking for JSON format ("transport": "http"), but the CLI actually returns human-readable text format ("Type: http"). This caused the transport mismatch detection to never trigger, allowing stdio to be selected in the UI while HTTP was registered with Claude Code. Changes: - Fix parsing logic to check for "Type: http" or "Type: stdio" in CLI output - Add OnTransportChanged event to refresh client status when transport changes - Wire up event handler to trigger client status refresh on transport dropdown change This ensures that when the transport mode in Unity doesn't match what's registered with Claude Code, the UI will correctly show an error status with instructions to re-register. --- .../Clients/McpClientConfiguratorBase.cs | 29 +++++++++++++++++-- .../Connection/McpConnectionSection.cs | 2 ++ .../Editor/Windows/MCPForUnityEditorWindow.cs | 2 ++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 7d23e315c..bb1ca41b4 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -347,7 +347,6 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) return client.status; } - string args = "mcp list"; string projectDir = Path.GetDirectoryName(Application.dataPath); string pathPrepend = null; @@ -372,10 +371,34 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) } catch { } - if (ExecPath.TryRun(claudePath, args, projectDir, out var stdout, out _, 10000, pathPrepend)) + // Check if UnityMCP exists + if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out _, 10000, pathPrepend)) { - if (!string.IsNullOrEmpty(stdout) && stdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0) + if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0) { + // UnityMCP is registered - now verify transport mode matches + bool currentUseHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + + // Get detailed info about the registration to check transport type + if (ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out var getStdout, out _, 7000, pathPrepend)) + { + // Parse the output to determine registered transport mode + // The CLI output format contains "Type: http" or "Type: stdio" + bool registeredWithHttp = getStdout.Contains("Type: http", StringComparison.OrdinalIgnoreCase); + bool registeredWithStdio = getStdout.Contains("Type: stdio", StringComparison.OrdinalIgnoreCase); + + // Check for transport mismatch + if ((currentUseHttp && registeredWithStdio) || (!currentUseHttp && registeredWithHttp)) + { + string registeredTransport = registeredWithHttp ? "HTTP" : "stdio"; + string currentTransport = currentUseHttp ? "HTTP" : "stdio"; + string errorMsg = $"Transport mismatch: Claude Code is registered with {registeredTransport} but current setting is {currentTransport}. Click Configure to re-register."; + client.SetStatus(McpStatus.Error, errorMsg); + McpLog.Warn(errorMsg); + return client.status; + } + } + client.SetStatus(McpStatus.Configured); return client.status; } diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index 9b2cc9335..d19bab2b4 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -55,6 +55,7 @@ private enum TransportProtocol // Events public event Action OnManualConfigUpdateRequested; + public event Action OnTransportChanged; public VisualElement Root { get; private set; } @@ -115,6 +116,7 @@ private void RegisterCallbacks() UpdateHttpFieldVisibility(); RefreshHttpUi(); OnManualConfigUpdateRequested?.Invoke(); + OnTransportChanged?.Invoke(); McpLog.Info($"Transport changed to: {evt.newValue}"); }); diff --git a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs index b82f03c12..87c0f7d2f 100644 --- a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs @@ -175,6 +175,8 @@ public void CreateGUI() connectionSection = new McpConnectionSection(connectionRoot); connectionSection.OnManualConfigUpdateRequested += () => clientConfigSection?.UpdateManualConfiguration(); + connectionSection.OnTransportChanged += () => + clientConfigSection?.RefreshSelectedClient(); } // Load and initialize Client Configuration section From 5122d8b18c52ade6cf5749f442d666f7666a077d Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 29 Dec 2025 21:49:34 -0800 Subject: [PATCH 04/16] Fix Claude Code registration UI blocking and thread safety issues This commit resolves three issues with Claude Code registration: 1. UI blocking: Removed synchronous CheckStatus() call after registration that was blocking the editor. Status is now set immediately with async verification happening in the background. 2. Thread safety: Fixed "can only be called from the main thread" errors by capturing Application.dataPath and EditorPrefs.GetBool() on the main thread before spawning async status check tasks. 3. Transport mismatch detection: Transport mode changes now trigger immediate status checks to detect HTTP/stdio mismatches, instead of waiting for the 45-second refresh interval. The registration button now turns green immediately after successful registration without blocking, and properly detects transport mismatches when switching between HTTP and stdio modes. --- .../Clients/McpClientConfiguratorBase.cs | 25 ++++++++++++++----- .../ClientConfig/McpClientConfigSection.cs | 22 +++++++++++++--- .../Editor/Windows/MCPForUnityEditorWindow.cs | 2 +- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index bb1ca41b4..174176158 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -335,6 +335,12 @@ public ClaudeCliMcpConfigurator(McpClient client) : base(client) { } public override string GetConfigPath() => "Managed via Claude CLI"; public override McpStatus CheckStatus(bool attemptAutoRewrite = true) + { + return CheckStatusWithProjectDir(null, null, attemptAutoRewrite); + } + + // Internal version that accepts projectDir and useHttpTransport to allow calling from background threads + internal McpStatus CheckStatusWithProjectDir(string projectDir, bool? useHttpTransport, bool attemptAutoRewrite = true) { try { @@ -347,7 +353,11 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) return client.status; } - string projectDir = Path.GetDirectoryName(Application.dataPath); + // Use provided projectDir or get it from Application.dataPath (main thread only) + if (string.IsNullOrEmpty(projectDir)) + { + projectDir = Path.GetDirectoryName(Application.dataPath); + } string pathPrepend = null; if (Application.platform == RuntimePlatform.OSXEditor) @@ -372,15 +382,16 @@ public override McpStatus CheckStatus(bool attemptAutoRewrite = true) catch { } // Check if UnityMCP exists - if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out _, 10000, pathPrepend)) + if (ExecPath.TryRun(claudePath, "mcp list", projectDir, out var listStdout, out var listStderr, 10000, pathPrepend)) { if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0) { // UnityMCP is registered - now verify transport mode matches - bool currentUseHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + // Use provided value or get from EditorPrefs (main thread only) + bool currentUseHttp = useHttpTransport ?? EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); // Get detailed info about the registration to check transport type - if (ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out var getStdout, out _, 7000, pathPrepend)) + if (ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend)) { // Parse the output to determine registered transport mode // The CLI output format contains "Type: http" or "Type: stdio" @@ -495,7 +506,9 @@ private void Register() McpLog.Info($"Successfully registered with Claude Code using {(useHttpTransport ? "HTTP" : "stdio")} transport."); - CheckStatus(); + // Set status to Configured immediately after successful registration + // The UI will trigger an async verification check separately to avoid blocking + client.SetStatus(McpStatus.Configured); } private void Unregister() @@ -538,7 +551,7 @@ private void Unregister() } client.SetStatus(McpStatus.NotConfigured); - CheckStatus(); + // Status is already set - no need for blocking CheckStatus() call } public override string GetManualSnippet() diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index d779f864b..f6c1339b3 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -6,6 +6,7 @@ using System.Runtime.InteropServices; using System.Threading.Tasks; using MCPForUnity.Editor.Clients; +using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Models; using MCPForUnity.Editor.Services; @@ -288,13 +289,14 @@ private void OnCopyJsonClicked() McpLog.Info("Configuration copied to clipboard"); } - public void RefreshSelectedClient() + public void RefreshSelectedClient(bool forceImmediate = false) { if (selectedClientIndex >= 0 && selectedClientIndex < configurators.Count) { var client = configurators[selectedClientIndex]; - bool forceImmediate = client is not ClaudeCliMcpConfigurator; - RefreshClientStatus(client, forceImmediate); + // Force immediate for non-Claude CLI, or when explicitly requested + bool shouldForceImmediate = forceImmediate || client is not ClaudeCliMcpConfigurator; + RefreshClientStatus(client, shouldForceImmediate); UpdateManualConfiguration(); UpdateClaudeCliPathVisibility(); } @@ -336,9 +338,21 @@ private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImm statusRefreshInFlight.Add(client); ApplyStatusToUi(client, showChecking: true); + // Capture main-thread-only values before async task + string projectDir = Path.GetDirectoryName(Application.dataPath); + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + Task.Run(() => { - MCPServiceLocator.Client.CheckClientStatus(client, attemptAutoRewrite: false); + // For Claude CLI configurator, use thread-safe version with captured values + if (client is ClaudeCliMcpConfigurator claudeConfigurator) + { + claudeConfigurator.CheckStatusWithProjectDir(projectDir, useHttpTransport, attemptAutoRewrite: false); + } + else + { + MCPServiceLocator.Client.CheckClientStatus(client, attemptAutoRewrite: false); + } }).ContinueWith(t => { bool faulted = false; diff --git a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs index 87c0f7d2f..d818edd05 100644 --- a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs @@ -176,7 +176,7 @@ public void CreateGUI() connectionSection.OnManualConfigUpdateRequested += () => clientConfigSection?.UpdateManualConfiguration(); connectionSection.OnTransportChanged += () => - clientConfigSection?.RefreshSelectedClient(); + clientConfigSection?.RefreshSelectedClient(forceImmediate: true); } // Load and initialize Client Configuration section From 0413c45fb28608eaf9779126089acc1d9376919f Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 29 Dec 2025 22:02:04 -0800 Subject: [PATCH 05/16] Enforce thread safety for Claude Code status checks at compile time Address code review feedback by making CheckStatusWithProjectDir thread-safe by design rather than by convention: 1. Made projectDir and useHttpTransport parameters non-nullable to prevent accidental background thread calls without captured values 2. Removed nullable fallback to EditorPrefs.GetBool() which would cause thread safety violations if called from background threads 3. Added ArgumentNullException for null projectDir instead of falling back to Application.dataPath (which is main-thread only) 4. Added XML documentation clearly stating threading contracts: - CheckStatus() must be called from main thread - CheckStatusWithProjectDir() is safe for background threads 5. Removed unreachable else branch in async status check code These changes make it impossible to misuse the API from background threads, with compile-time enforcement instead of runtime errors. --- .../Clients/McpClientConfiguratorBase.cs | 25 +++++++++++++------ .../ClientConfig/McpClientConfigSection.cs | 13 +++------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs index 174176158..43562e5c8 100644 --- a/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs +++ b/MCPForUnity/Editor/Clients/McpClientConfiguratorBase.cs @@ -334,13 +334,24 @@ public ClaudeCliMcpConfigurator(McpClient client) : base(client) { } public override string GetConfigPath() => "Managed via Claude CLI"; + /// + /// Checks the Claude CLI registration status. + /// MUST be called from the main Unity thread due to EditorPrefs and Application.dataPath access. + /// public override McpStatus CheckStatus(bool attemptAutoRewrite = true) { - return CheckStatusWithProjectDir(null, null, attemptAutoRewrite); + // Capture main-thread-only values before delegating to thread-safe method + string projectDir = Path.GetDirectoryName(Application.dataPath); + bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + return CheckStatusWithProjectDir(projectDir, useHttpTransport, attemptAutoRewrite); } - // Internal version that accepts projectDir and useHttpTransport to allow calling from background threads - internal McpStatus CheckStatusWithProjectDir(string projectDir, bool? useHttpTransport, bool attemptAutoRewrite = true) + /// + /// Internal thread-safe version of CheckStatus. + /// Can be called from background threads because all main-thread-only values are passed as parameters. + /// Both projectDir and useHttpTransport are REQUIRED (non-nullable) to enforce thread safety at compile time. + /// + internal McpStatus CheckStatusWithProjectDir(string projectDir, bool useHttpTransport, bool attemptAutoRewrite = true) { try { @@ -353,10 +364,10 @@ internal McpStatus CheckStatusWithProjectDir(string projectDir, bool? useHttpTra return client.status; } - // Use provided projectDir or get it from Application.dataPath (main thread only) + // projectDir is required - no fallback to Application.dataPath if (string.IsNullOrEmpty(projectDir)) { - projectDir = Path.GetDirectoryName(Application.dataPath); + throw new ArgumentNullException(nameof(projectDir), "Project directory must be provided for thread-safe execution"); } string pathPrepend = null; @@ -387,8 +398,8 @@ internal McpStatus CheckStatusWithProjectDir(string projectDir, bool? useHttpTra if (!string.IsNullOrEmpty(listStdout) && listStdout.IndexOf("UnityMCP", StringComparison.OrdinalIgnoreCase) >= 0) { // UnityMCP is registered - now verify transport mode matches - // Use provided value or get from EditorPrefs (main thread only) - bool currentUseHttp = useHttpTransport ?? EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + // useHttpTransport parameter is required (non-nullable) to ensure thread safety + bool currentUseHttp = useHttpTransport; // Get detailed info about the registration to check transport type if (ExecPath.TryRun(claudePath, "mcp get UnityMCP", projectDir, out var getStdout, out var getStderr, 7000, pathPrepend)) diff --git a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs index f6c1339b3..9c2dd795b 100644 --- a/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs +++ b/MCPForUnity/Editor/Windows/Components/ClientConfig/McpClientConfigSection.cs @@ -344,15 +344,10 @@ private void RefreshClaudeCliStatus(IMcpClientConfigurator client, bool forceImm Task.Run(() => { - // For Claude CLI configurator, use thread-safe version with captured values - if (client is ClaudeCliMcpConfigurator claudeConfigurator) - { - claudeConfigurator.CheckStatusWithProjectDir(projectDir, useHttpTransport, attemptAutoRewrite: false); - } - else - { - MCPServiceLocator.Client.CheckClientStatus(client, attemptAutoRewrite: false); - } + // This method is only called for Claude CLI configurators, so we can safely cast + // Use thread-safe version with captured main-thread values + var claudeConfigurator = (ClaudeCliMcpConfigurator)client; + claudeConfigurator.CheckStatusWithProjectDir(projectDir, useHttpTransport, attemptAutoRewrite: false); }).ContinueWith(t => { bool faulted = false; From 3749d4dd2feeea1685d2b8f4ddd4a2f8b39db97b Mon Sep 17 00:00:00 2001 From: David Sarno Date: Mon, 29 Dec 2025 22:29:54 -0800 Subject: [PATCH 06/16] Consolidate local HTTP Start/Stop and auto-start session --- .../Connection/McpConnectionSection.cs | 146 +++++++++++++----- .../Connection/McpConnectionSection.uxml | 3 +- 2 files changed, 109 insertions(+), 40 deletions(-) diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index d19bab2b4..d6b0db291 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -44,6 +44,8 @@ private enum TransportProtocol private Button testConnectionButton; private bool connectionToggleInProgress; + private bool autoStartInProgress; + private bool httpServerToggleInProgress; private Task verificationTask; private string lastHealthStatus; @@ -115,6 +117,7 @@ private void RegisterCallbacks() EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, useHttp); UpdateHttpFieldVisibility(); RefreshHttpUi(); + UpdateConnectionStatus(); OnManualConfigUpdateRequested?.Invoke(); OnTransportChanged?.Invoke(); McpLog.Info($"Transport changed to: {evt.newValue}"); @@ -130,12 +133,19 @@ private void RegisterCallbacks() if (startHttpServerButton != null) { - startHttpServerButton.clicked += OnStartLocalHttpServerClicked; + startHttpServerButton.clicked += OnHttpServerToggleClicked; } if (stopHttpServerButton != null) { - stopHttpServerButton.clicked += OnStopLocalHttpServerClicked; + // Stop button removed from UXML as part of consolidated Start/Stop UX. + // Kept null-check for backward compatibility if older UXML is loaded. + stopHttpServerButton.clicked += () => + { + // In older UXML layouts, route the stop button to the consolidated toggle behavior. + // If a session is active, this will end it and attempt to stop the local server. + OnHttpServerToggleClicked(); + }; } if (copyHttpServerCommandButton != null) @@ -168,6 +178,15 @@ public void UpdateConnectionStatus() { var bridgeService = MCPServiceLocator.Bridge; bool isRunning = bridgeService.IsRunning; + bool showLocalServerControls = MCPServiceLocator.Server.CanStartLocalServer(); + + // If local-server controls are active, hide the manual session toggle controls and + // rely on the Start/Stop Server button. We still keep the session status text visible + // next to the dot for clarity. + if (connectionToggleButton != null) + { + connectionToggleButton.style.display = showLocalServerControls ? DisplayStyle.None : DisplayStyle.Flex; + } if (isRunning) { @@ -209,8 +228,11 @@ public void UpdateHttpServerCommandDisplay() } bool useHttp = transportDropdown != null && (TransportProtocol)transportDropdown.value == TransportProtocol.HTTP; + bool isLocalHttpUrl = MCPServiceLocator.Server.IsLocalUrl(); - if (!useHttp) + // Only show the local-server helper UI when HTTP transport is selected AND the configured URL is local. + // For remote HTTP URLs, the "start local server" command/buttons are not applicable and are hidden. + if (!useHttp || !isLocalHttpUrl) { httpServerCommandSection.style.display = DisplayStyle.None; httpServerCommandField.value = string.Empty; @@ -277,19 +299,19 @@ private void UpdateStartHttpButtonState() return; } - bool canStart = MCPServiceLocator.Server.CanStartLocalServer(); - startHttpServerButton.SetEnabled(canStart); - startHttpServerButton.tooltip = canStart + bool canStartLocalServer = MCPServiceLocator.Server.CanStartLocalServer(); + bool sessionRunning = MCPServiceLocator.Bridge.IsRunning; + + // Single consolidated button: Start Server (launch local server + start session) or + // Stop Server (end session + attempt to stop local server). + startHttpServerButton.text = sessionRunning ? "Stop Server" : "Start Server"; + startHttpServerButton.SetEnabled(canStartLocalServer && !httpServerToggleInProgress && !autoStartInProgress); + startHttpServerButton.tooltip = canStartLocalServer ? string.Empty - : "Start Local HTTP Server is available only for localhost URLs."; + : "Local server controls are available only for localhost URLs."; - if (stopHttpServerButton != null) - { - stopHttpServerButton.SetEnabled(canStart); - stopHttpServerButton.tooltip = canStart - ? string.Empty - : "Stop Local HTTP Server is available only for localhost URLs."; - } + // Stop button is no longer used; it may be null depending on UXML version. + stopHttpServerButton?.SetEnabled(false); } private void RefreshHttpUi() @@ -298,46 +320,48 @@ private void RefreshHttpUi() UpdateHttpServerCommandDisplay(); } - private void OnStartLocalHttpServerClicked() + private async void OnHttpServerToggleClicked() { - if (startHttpServerButton != null) + if (httpServerToggleInProgress) { - startHttpServerButton.SetEnabled(false); - } - - try - { - MCPServiceLocator.Server.StartLocalHttpServer(); - } - finally - { - RefreshHttpUi(); + return; } - } - private void OnStopLocalHttpServerClicked() - { - if (stopHttpServerButton != null) - { - stopHttpServerButton.SetEnabled(false); - } + var bridgeService = MCPServiceLocator.Bridge; + httpServerToggleInProgress = true; + startHttpServerButton?.SetEnabled(false); try { - bool stopped = MCPServiceLocator.Server.StopLocalHttpServer(); - if (!stopped) + // If a session is active, treat this as "Stop Server" (end session first, then try to stop server). + if (bridgeService.IsRunning) + { + await bridgeService.StopAsync(); + bool stopped = MCPServiceLocator.Server.StopLocalHttpServer(); + if (!stopped) + { + McpLog.Warn("Failed to stop HTTP server or no server was running"); + } + return; + } + + // Otherwise, "Start Server" and then auto-start the session. + bool started = MCPServiceLocator.Server.StartLocalHttpServer(); + if (started) { - McpLog.Warn("Failed to stop HTTP server or no server was running"); + await TryAutoStartSessionAfterHttpServerAsync(); } } catch (Exception ex) { - McpLog.Error($"Failed to stop server: {ex.Message}"); - EditorUtility.DisplayDialog("Error", $"Failed to stop server:\n\n{ex.Message}", "OK"); + McpLog.Error($"HTTP server toggle failed: {ex.Message}"); + EditorUtility.DisplayDialog("Error", $"Failed to toggle local HTTP server:\n\n{ex.Message}", "OK"); } finally { + httpServerToggleInProgress = false; RefreshHttpUi(); + UpdateConnectionStatus(); } } @@ -422,6 +446,52 @@ private async void OnTestConnectionClicked() await VerifyBridgeConnectionAsync(); } + private async Task TryAutoStartSessionAfterHttpServerAsync() + { + if (autoStartInProgress) + { + return; + } + + var bridgeService = MCPServiceLocator.Bridge; + if (bridgeService.IsRunning) + { + return; + } + + autoStartInProgress = true; + connectionToggleButton?.SetEnabled(false); + const int maxAttempts = 10; + var delay = TimeSpan.FromSeconds(1); + + try + { + for (int attempt = 0; attempt < maxAttempts; attempt++) + { + bool started = await bridgeService.StartAsync(); + if (started) + { + await VerifyBridgeConnectionAsync(); + UpdateConnectionStatus(); + return; + } + + if (attempt < maxAttempts - 1) + { + await Task.Delay(delay); + } + } + + McpLog.Warn("Failed to auto-start MCP session after launching the HTTP server."); + } + finally + { + autoStartInProgress = false; + connectionToggleButton?.SetEnabled(true); + UpdateConnectionStatus(); + } + } + public async Task VerifyBridgeConnectionAsync() { // Prevent concurrent verification calls diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.uxml b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.uxml index b33ad8ee8..15622cb12 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.uxml +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.uxml @@ -19,8 +19,7 @@ - - + From bc8f4518267af72ec6924ec50c476dea28b56e7c Mon Sep 17 00:00:00 2001 From: David Sarno Date: Tue, 30 Dec 2025 14:14:06 -0800 Subject: [PATCH 07/16] HTTP improvements: Unity-owned server lifecycle + UI polish --- .../Editor/Constants/EditorPrefKeys.cs | 5 + .../Editor/Services/BridgeControlService.cs | 18 + .../Services/IServerManagementService.cs | 6 + .../Services/McpEditorShutdownCleanup.cs | 75 ++ .../Services/McpEditorShutdownCleanup.cs.meta | 11 + .../Services/ServerManagementService.cs | 819 ++++++++++++++++-- .../Editor/Windows/Components/Common.uss | 20 +- .../Connection/McpConnectionSection.cs | 242 +++++- .../Connection/McpConnectionSection.uxml | 4 +- .../Settings/McpSettingsSection.uxml | 2 +- .../Windows/EditorPrefs/EditorPrefsWindow.cs | 1 + 11 files changed, 1126 insertions(+), 77 deletions(-) create mode 100644 MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs create mode 100644 MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta diff --git a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs index 25542ab06..b4b4c7649 100644 --- a/MCPForUnity/Editor/Constants/EditorPrefKeys.cs +++ b/MCPForUnity/Editor/Constants/EditorPrefKeys.cs @@ -7,6 +7,11 @@ namespace MCPForUnity.Editor.Constants 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 DebugLogs = "MCPForUnity.DebugLogs"; internal const string ValidationLevel = "MCPForUnity.ValidationLevel"; internal const string UnitySocketPort = "MCPForUnity.UnitySocketPort"; diff --git a/MCPForUnity/Editor/Services/BridgeControlService.cs b/MCPForUnity/Editor/Services/BridgeControlService.cs index 0786de056..4057adfb6 100644 --- a/MCPForUnity/Editor/Services/BridgeControlService.cs +++ b/MCPForUnity/Editor/Services/BridgeControlService.cs @@ -82,6 +82,24 @@ public async Task StartAsync() var mode = ResolvePreferredMode(); try { + // Treat transports as mutually exclusive for user-driven session starts: + // stop the *other* transport first to avoid duplicated sessions (e.g. stdio lingering when switching to HTTP). + var otherMode = mode == TransportMode.Http ? TransportMode.Stdio : TransportMode.Http; + try + { + await _transportManager.StopAsync(otherMode); + } + catch (Exception ex) + { + McpLog.Warn($"Error stopping other transport ({otherMode}) before start: {ex.Message}"); + } + + // Legacy safety: stdio may have been started outside TransportManager state. + if (otherMode == TransportMode.Stdio) + { + try { StdioBridgeHost.Stop(); } catch { } + } + bool started = await _transportManager.StartAsync(mode); if (!started) { diff --git a/MCPForUnity/Editor/Services/IServerManagementService.cs b/MCPForUnity/Editor/Services/IServerManagementService.cs index 54c7b9c35..4b1ec3984 100644 --- a/MCPForUnity/Editor/Services/IServerManagementService.cs +++ b/MCPForUnity/Editor/Services/IServerManagementService.cs @@ -23,6 +23,12 @@ public interface IServerManagementService /// bool StopLocalHttpServer(); + /// + /// 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). + /// + bool IsLocalHttpServerRunning(); + /// /// Attempts to get the command that will be executed when starting the local HTTP server /// diff --git a/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs new file mode 100644 index 000000000..9e0f7b383 --- /dev/null +++ b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs @@ -0,0 +1,75 @@ +using System; +using System.Threading.Tasks; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services.Transport; +using UnityEditor; + +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. + /// - If HTTP Local is selected, attempts to stop the local HTTP server (guarded by PID heuristics). + /// + [InitializeOnLoad] + internal static class McpEditorShutdownCleanup + { + static McpEditorShutdownCleanup() + { + // Guard against duplicate subscriptions across domain reloads. + try { EditorApplication.quitting -= OnEditorQuitting; } catch { } + EditorApplication.quitting += OnEditorQuitting; + } + + private static void OnEditorQuitting() + { + // 1) Stop transports (best-effort, bounded wait). + try + { + var transport = MCPServiceLocator.TransportManager; + + Task stopHttp = transport.StopAsync(TransportMode.Http); + Task stopStdio = transport.StopAsync(TransportMode.Stdio); + + try { Task.WaitAll(new[] { stopHttp, stopStdio }, 750); } catch { } + } + catch (Exception ex) + { + // Avoid hard failures on quit. + McpLog.Warn($"Shutdown cleanup: failed to stop transports: {ex.Message}"); + } + + // 2) Stop local HTTP server if the user selected HTTP Local (best-effort). + try + { + bool useHttp = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); + if (!useHttp) + { + return; + } + + // Prefer explicit scope if present; fall back to URL heuristics for backward compatibility. + string scope = string.Empty; + try { scope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty); } catch { } + + bool httpLocalSelected = string.Equals(scope, "local", StringComparison.OrdinalIgnoreCase) + || (string.IsNullOrEmpty(scope) && MCPServiceLocator.Server.IsLocalUrl()); + + if (!httpLocalSelected) + { + return; + } + + // StopLocalHttpServer is already guarded to only terminate processes that look like mcp-for-unity. + MCPServiceLocator.Server.StopLocalHttpServer(); + } + catch (Exception ex) + { + McpLog.Warn($"Shutdown cleanup: failed to stop local HTTP server: {ex.Message}"); + } + } + } +} + + diff --git a/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta new file mode 100644 index 000000000..a94395c66 --- /dev/null +++ b/MCPForUnity/Editor/Services/McpEditorShutdownCleanup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4150c04e0907c45d7b332260911a0567 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Services/ServerManagementService.cs b/MCPForUnity/Editor/Services/ServerManagementService.cs index b081dada0..63948c00c 100644 --- a/MCPForUnity/Editor/Services/ServerManagementService.cs +++ b/MCPForUnity/Editor/Services/ServerManagementService.cs @@ -2,6 +2,9 @@ using System.IO; using System.Linq; using System.Collections.Generic; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using UnityEditor; @@ -14,6 +17,136 @@ namespace MCPForUnity.Editor.Services /// public class ServerManagementService : IServerManagementService { + private static readonly HashSet LoggedStopDiagnosticsPids = new HashSet(); + private static readonly object LocalHttpServerProcessLock = new object(); + private static System.Diagnostics.Process LocalHttpServerProcess; + private static bool OpenedHttpServerLogViewerThisSession; + + private static string GetProjectRootPath() + { + try + { + // Application.dataPath is "...//Assets" + return Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + } + catch + { + return Application.dataPath; + } + } + + private static string GetLocalHttpServerLogDirectory() + { + // Prefer Library so it stays project-scoped and out of version control. + return Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "Logs"); + } + + private static string GetLocalHttpServerLogPath() + { + return Path.Combine(GetLocalHttpServerLogDirectory(), "mcp_http_server.log"); + } + + private static string QuoteIfNeeded(string s) + { + if (string.IsNullOrEmpty(s)) return s; + return s.IndexOf(' ') >= 0 ? $"\"{s}\"" : s; + } + + private static string NormalizeForMatch(string s) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + var sb = new StringBuilder(s.Length); + foreach (char c in s) + { + if (char.IsWhiteSpace(c)) continue; + sb.Append(char.ToLowerInvariant(c)); + } + return sb.ToString(); + } + + private static void ClearLocalServerPidTracking() + { + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPid); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPort); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerStartedUtc); } catch { } + try { EditorPrefs.DeleteKey(EditorPrefKeys.LastLocalHttpServerPidArgsHash); } catch { } + } + + private static void StoreLocalServerPidTracking(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 { } + } + + private static 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 bool TryGetStoredLocalServerPid(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; + } + } + /// /// Clear the local uvx cache for the MCP server package /// @@ -155,12 +288,12 @@ private string GetPlatformSpecificPathPrepend() } /// - /// Start the local HTTP server in a new terminal window. + /// Start the local HTTP server as a Unity-owned process. /// Stops any existing server on the port and clears the uvx cache first. /// public bool StartLocalHttpServer() { - if (!TryGetLocalHttpServerCommand(out var command, out var error)) + if (!TryGetLocalHttpServerCommandParts(out var fileName, out var arguments, out var displayCommand, out var error)) { EditorUtility.DisplayDialog( "Cannot Start HTTP Server", @@ -169,29 +302,147 @@ public bool StartLocalHttpServer() return false; } - // First, try to stop any existing server - StopLocalHttpServer(); + // First, try to stop any existing server (quietly; we'll only warn if the port remains occupied). + StopLocalHttpServerInternal(quiet: true); + + // If the port is still occupied, don't start and explain why (avoid confusing "refusing to stop" warnings). + try + { + string httpUrl = HttpEndpointUtility.GetBaseUrl(); + if (Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) && uri.Port > 0) + { + var remaining = GetListeningProcessIdsForPort(uri.Port); + if (remaining.Count > 0) + { + EditorUtility.DisplayDialog( + "Port In Use", + $"Cannot start the local HTTP server because port {uri.Port} is already in use by PID(s): " + + $"{string.Join(", ", remaining)}\n\n" + + "MCP For Unity will not terminate unrelated processes. Stop the owning process manually or change the HTTP URL.", + "OK"); + return false; + } + } + } + catch { } // Note: Dev mode cache-busting is handled by `uvx --no-cache --refresh` in the generated command. + string logPath = GetLocalHttpServerLogPath(); if (EditorUtility.DisplayDialog( "Start Local HTTP Server", - $"This will start the MCP server in HTTP mode:\n\n{command}\n\n" + - "The server will run in a separate terminal window. " + - "Close the terminal to stop the server.\n\n" + + $"This will start the MCP server in HTTP mode:\n\n{displayCommand}\n\n" + + "The server will run as a background process managed by Unity.\n\n" + + $"Logs will be written to:\n{logPath}\n\n" + "Continue?", "Start Server", "Cancel")) { try { - // Start the server in a new terminal window (cross-platform) - var startInfo = CreateTerminalProcessStartInfo(command); + Directory.CreateDirectory(GetLocalHttpServerLogDirectory()); - System.Diagnostics.Process.Start(startInfo); + // Start the server as a child process owned by Unity (no Terminal / AppleScript ownership issues). + var startInfo = CreateLocalHttpServerProcessStartInfo(fileName, arguments); + var process = new System.Diagnostics.Process + { + StartInfo = startInfo, + EnableRaisingEvents = true + }; + + // Append-only log file, so restarts keep history. + StreamWriter logWriter = null; + bool startedProcess = false; + object logLock = new object(); + try + { + logWriter = new StreamWriter(logPath, append: true, encoding: new UTF8Encoding(false)) { AutoFlush = true }; - McpLog.Info($"Started local HTTP server: {command}"); - return true; + process.OutputDataReceived += (_, e) => + { + if (e.Data == null) return; + try + { + lock (logLock) + { + if (logWriter == null) return; + logWriter.WriteLine(e.Data); + } + } + catch { } + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data == null) return; + try + { + lock (logLock) + { + if (logWriter == null) return; + logWriter.WriteLine(e.Data); + } + } + catch { } + }; + process.Exited += (_, _) => + { + try + { + lock (logLock) + { + if (logWriter != null) + { + logWriter.WriteLine($"[MCPForUnity] Server process exited (PID {process.Id}) at {DateTime.UtcNow:O}"); + logWriter.Flush(); + logWriter.Dispose(); + logWriter = null; + } + } + } + catch { } + }; + + lock (logLock) + { + logWriter.WriteLine(); + logWriter.WriteLine($"[MCPForUnity] Starting local HTTP server at {DateTime.UtcNow:O}"); + logWriter.WriteLine($"[MCPForUnity] Command: {displayCommand}"); + } + + if (!process.Start()) + { + throw new Exception("Failed to start the server process."); + } + startedProcess = true; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + lock (LocalHttpServerProcessLock) + { + try { LocalHttpServerProcess?.Dispose(); } catch { } + LocalHttpServerProcess = process; + } + + McpLog.Info($"Started local HTTP server (PID {process.Id}): {displayCommand}"); + + // Best-effort: capture and remember the actual PID that bound the port so Stop Server can be reliable. + // (uvx/uvicorn can involve wrapper/reloader processes; port PID capture is the most accurate.) + TryCaptureAndStoreLocalServerPid(); + + // Optional UX: open a Terminal window that tails the log file (no AppleScript required). + TryOpenLogInTerminal(logPath); + return true; + } + catch + { + // If we never actually started the process, make sure the log writer isn't leaked. + if (!startedProcess) + { + try { logWriter?.Dispose(); } catch { } + } + throw; + } } catch (Exception ex) { @@ -207,15 +458,233 @@ public bool StartLocalHttpServer() return false; } + private System.Diagnostics.ProcessStartInfo CreateLocalHttpServerProcessStartInfo(string fileName, string arguments) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException("Executable cannot be empty", nameof(fileName)); + } + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = fileName, + Arguments = arguments ?? string.Empty, + WorkingDirectory = GetProjectRootPath(), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + // Unity's environment PATH can be missing common package manager locations; + // prepend platform-specific bins so uv/uvx resolves reliably. + string extraPathPrepend = GetPlatformSpecificPathPrepend(); + if (!string.IsNullOrEmpty(extraPathPrepend)) + { + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) + ? extraPathPrepend + : (extraPathPrepend + Path.PathSeparator + currentPath); + } + + return psi; + } + + private void TryOpenLogInTerminal(string logPath) + { + try + { + if (string.IsNullOrEmpty(logPath)) + { + return; + } + + // Note: + // - The server may log transient HTTP 400 responses on /mcp during startup probing/retries. + // - On shutdown, uvicorn/FastMCP may log CancelledError stack traces when streaming requests are cancelled. + // These are expected and not necessarily indicative of a Unity-side problem. + + // Best-effort: don't keep spawning new Terminal windows/tabs for the same log file every time the server is restarted. + // Without AppleScript, we can't reliably detect whether a Terminal window is already tailing this file, so we + // avoid re-opening within the same Unity Editor session. + if (OpenedHttpServerLogViewerThisSession) + { + return; + } + + string tailCommand = $"tail -n 200 -f \"{logPath.Replace("\"", "\\\"")}\""; + +#if UNITY_EDITOR_OSX + // Avoid AppleScript (which can trigger automation permission prompts). + // Create a .command script and open it with Terminal. + string scriptsDir = Path.Combine(GetProjectRootPath(), "Library", "MCPForUnity", "TerminalScripts"); + Directory.CreateDirectory(scriptsDir); + string scriptPath = Path.Combine(scriptsDir, "tail-mcp-http-server.command"); + File.WriteAllText( + scriptPath, + "#!/bin/bash\n" + + "set -e\n" + + "clear\n" + + "echo \"Tailing MCP For Unity server log...\"\n" + + $"{tailCommand}\n"); + + ExecPath.TryRun("/bin/chmod", $"+x \"{scriptPath}\"", Application.dataPath, out _, out _, 3000); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/open", + Arguments = $"-a Terminal \"{scriptPath}\"", + UseShellExecute = false, + CreateNoWindow = true + }); + OpenedHttpServerLogViewerThisSession = true; +#elif UNITY_EDITOR_WIN + // Use PowerShell tail for better behavior. + string ps = $"powershell -NoProfile -Command \"Get-Content -Path '{logPath.Replace("'", "''")}' -Wait -Tail 200\""; + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c start \"MCP Server Logs\" {ps}", + UseShellExecute = false, + CreateNoWindow = true + }); + OpenedHttpServerLogViewerThisSession = true; +#else + // Linux: reuse terminal launcher for a simple tail command. + var startInfo = CreateTerminalProcessStartInfo(tailCommand); + System.Diagnostics.Process.Start(startInfo); + OpenedHttpServerLogViewerThisSession = true; +#endif + } + catch { } + } + + private void TryCaptureAndStoreLocalServerPid() + { + try + { + string httpUrl = HttpEndpointUtility.GetBaseUrl(); + if (!IsLocalUrl(httpUrl)) + { + return; + } + + if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri)) + { + return; + } + + int port = uri.Port; + if (port <= 0) return; + + // Poll briefly for the listener to appear. + int unityPid = GetCurrentProcessIdSafe(); + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < deadline) + { + var pids = GetListeningProcessIdsForPort(port); + foreach (var pid in pids) + { + if (pid <= 0) continue; + if (unityPid > 0 && pid == unityPid) continue; + + // At this point StartLocalHttpServer already ensured the port was free before launching. + // So whichever PID is now listening on the port is very likely the server we started. + // We store a short fingerprint of the process args to reduce PID-reuse risk across domain reloads. + string argsHash = string.Empty; + if (TryGetUnixProcessArgs(pid, out var argsLower)) + { + argsHash = ComputeShortHash(argsLower); + } + StoreLocalServerPidTracking(pid, port, argsHash); + return; + } + + System.Threading.Thread.Sleep(150); + } + } + catch { } + } + /// /// Stop the local HTTP server by finding the process listening on the configured port /// public bool StopLocalHttpServer() + { + return StopLocalHttpServerInternal(quiet: false); + } + + public bool IsLocalHttpServerRunning() + { + try + { + string httpUrl = HttpEndpointUtility.GetBaseUrl(); + if (!IsLocalUrl(httpUrl)) + { + return false; + } + + if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) || uri.Port <= 0) + { + return false; + } + + int port = uri.Port; + + // Fast path: if Unity still has a live Process handle, trust it. + lock (LocalHttpServerProcessLock) + { + try + { + if (LocalHttpServerProcess != null && !LocalHttpServerProcess.HasExited) + { + return true; + } + } + catch { } + } + + var pids = GetListeningProcessIdsForPort(port); + if (pids.Count == 0) + { + return false; + } + + // Strong signal: stored PID is still the listener. + if (TryGetStoredLocalServerPid(port, out int storedPid) && storedPid > 0) + { + if (pids.Contains(storedPid)) + { + return true; + } + } + + // Best-effort: if anything listening looks like our server, treat as running. + foreach (var pid in pids) + { + if (pid <= 0) continue; + if (LooksLikeMcpServerProcess(pid)) + { + return true; + } + } + + return false; + } + catch + { + return false; + } + } + + private bool StopLocalHttpServerInternal(bool quiet) { string httpUrl = HttpEndpointUtility.GetBaseUrl(); if (!IsLocalUrl(httpUrl)) { - McpLog.Warn("Cannot stop server: URL is not local."); + if (!quiet) + { + McpLog.Warn("Cannot stop server: URL is not local."); + } return false; } @@ -226,7 +695,10 @@ public bool StopLocalHttpServer() if (port <= 0) { - McpLog.Warn("Cannot stop server: Invalid port."); + if (!quiet) + { + McpLog.Warn("Cannot stop server: Invalid port."); + } return false; } @@ -235,27 +707,138 @@ public bool StopLocalHttpServer() // - Only terminate processes that look like the MCP server (uv/uvx/python running mcp-for-unity). // This prevents accidental termination of unrelated services (including Unity itself). int unityPid = GetCurrentProcessIdSafe(); + bool stoppedAny = false; + + // If Unity started a local server process in this editor session, try to terminate it first. + // (We still fall back to port-based termination for robustness.) + lock (LocalHttpServerProcessLock) + { + try + { + if (LocalHttpServerProcess != null && !LocalHttpServerProcess.HasExited) + { + int pidToKill = LocalHttpServerProcess.Id; + if (unityPid <= 0 || pidToKill != unityPid) + { + if (TerminateProcess(pidToKill)) + { + stoppedAny = true; + } + } + } + } + catch { } + finally + { + try { LocalHttpServerProcess?.Dispose(); } catch { } + LocalHttpServerProcess = null; + } + } var pids = GetListeningProcessIdsForPort(port); if (pids.Count == 0) { - McpLog.Info($"No process found listening on port {port}"); + if (stoppedAny) + { + // We stopped what Unity started; the port is now free. + if (!quiet) + { + McpLog.Info($"Stopped local HTTP server on port {port}"); + } + ClearLocalServerPidTracking(); + return true; + } + + if (!quiet) + { + McpLog.Info($"No process found listening on port {port}"); + } + ClearLocalServerPidTracking(); return false; } - bool stoppedAny = false; + // Prefer killing the PID that we previously observed binding this port (if still valid). + if (TryGetStoredLocalServerPid(port, out int storedPid)) + { + if (pids.Contains(storedPid)) + { + string expectedHash = string.Empty; + try { expectedHash = EditorPrefs.GetString(EditorPrefKeys.LastLocalHttpServerPidArgsHash, string.Empty); } catch { } + + // Prefer a fingerprint match (reduces PID reuse risk). If missing (older installs), + // fall back to a looser check to avoid leaving orphaned servers after domain reload. + if (TryGetUnixProcessArgs(storedPid, out var storedArgsLowerNow)) + { + // Never kill Unity/Hub. + if (storedArgsLowerNow.Contains("unityhub") || storedArgsLowerNow.Contains("unity hub") || storedArgsLowerNow.Contains("unity")) + { + if (!quiet) + { + McpLog.Warn($"Refusing to stop port {port}: stored PID {storedPid} appears to be a Unity process."); + } + } + else + { + bool allowKill = false; + if (!string.IsNullOrEmpty(expectedHash)) + { + allowKill = string.Equals(expectedHash, ComputeShortHash(storedArgsLowerNow), StringComparison.OrdinalIgnoreCase); + } + else + { + // Older versions didn't store a fingerprint; accept common server indicators. + allowKill = storedArgsLowerNow.Contains("uvicorn") + || storedArgsLowerNow.Contains("fastmcp") + || storedArgsLowerNow.Contains("mcpforunity") + || storedArgsLowerNow.Contains("mcp-for-unity") + || storedArgsLowerNow.Contains("mcp_for_unity") + || storedArgsLowerNow.Contains("uvx") + || storedArgsLowerNow.Contains("python"); + } + + if (allowKill && TerminateProcess(storedPid)) + { + if (!quiet) + { + McpLog.Info($"Stopped local HTTP server on port {port} (PID: {storedPid})"); + } + stoppedAny = true; + ClearLocalServerPidTracking(); + // Refresh the PID list to avoid double-work. + pids = GetListeningProcessIdsForPort(port); + } + else if (!allowKill && !quiet) + { + McpLog.Warn($"Refusing to stop port {port}: stored PID {storedPid} did not match expected server fingerprint."); + } + } + } + } + else + { + // Stale PID (no longer listening). Clear. + ClearLocalServerPidTracking(); + } + } + foreach (var pid in pids) { if (pid <= 0) continue; if (unityPid > 0 && pid == unityPid) { - McpLog.Warn($"Refusing to stop port {port}: owning PID appears to be the Unity Editor process (PID {pid})."); + if (!quiet) + { + 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)."); + if (!quiet) + { + McpLog.Warn($"Refusing to stop port {port}: owning PID {pid} does not look like mcp-for-unity."); + } continue; } @@ -266,7 +849,10 @@ public bool StopLocalHttpServer() } else { - McpLog.Warn($"Failed to stop process PID {pid} on port {port}"); + if (!quiet) + { + McpLog.Warn($"Failed to stop process PID {pid} on port {port}"); + } } } @@ -274,7 +860,36 @@ public bool StopLocalHttpServer() } catch (Exception ex) { - McpLog.Error($"Failed to stop server: {ex.Message}"); + if (!quiet) + { + McpLog.Error($"Failed to stop server: {ex.Message}"); + } + return false; + } + } + + private static bool TryGetUnixProcessArgs(int pid, out string argsLower) + { + argsLower = string.Empty; + try + { + if (Application.platform == RuntimePlatform.WindowsEditor) + { + return false; + } + + string psPath = "/bin/ps"; + if (!File.Exists(psPath)) psPath = "ps"; + + ExecPath.TryRun(psPath, $"-p {pid} -ww -o args=", Application.dataPath, out var stdout, out var stderr, 5000); + 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; } } @@ -346,27 +961,31 @@ private bool LooksLikeMcpServerProcess(int pid) { try { + bool debugLogs = false; + try { debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false); } catch { } + // 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; + 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)).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"); } - // 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)) + // 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)) { - 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")) @@ -378,14 +997,33 @@ private bool LooksLikeMcpServerProcess(int pid) 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"); + bool mentionsUvicorn = s.Contains("uvicorn"); + bool mentionsMcp = sCompact.Contains("mcp-for-unity") + || sCompact.Contains("mcp_for_unity") + || sCompact.Contains("mcpforunity"); + bool mentionsTransport = sCompact.Contains("--transporthttp") || (sCompact.Contains("--transport") && sCompact.Contains("http")); + + // If it explicitly mentions the server package/entrypoint, that is sufficient + // (we already only evaluate this for PIDs that are listening on our configured port). + if (mentionsMcp) + { + return true; + } // Accept if it looks like uv/uvx/python launching our server package/entrypoint - if ((mentionsUvx || mentionsUv || mentionsPython) && (mentionsMcp || mentionsTransport)) + if ((mentionsUvx || mentionsUv || mentionsPython || mentionsUvicorn) && mentionsTransport) { return true; } + + if (debugLogs) + { + LogStopDiagnosticsOnce(pid, $"ps='{TrimForLog(s)}' uvx={mentionsUvx} uv={mentionsUv} py={mentionsPython} uvicorn={mentionsUvicorn} mcp={mentionsMcp} transportHttp={mentionsTransport}"); + } + } + else if (debugLogs) + { + LogStopDiagnosticsOnce(pid, "ps output was empty (could not classify process)."); } } catch { } @@ -393,6 +1031,28 @@ private bool LooksLikeMcpServerProcess(int pid) return false; } + private static void LogStopDiagnosticsOnce(int pid, string details) + { + try + { + if (LoggedStopDiagnosticsPids.Contains(pid)) + { + return; + } + LoggedStopDiagnosticsPids.Add(pid); + McpLog.Debug($"[StopLocalHttpServer] PID {pid} did not match server heuristics. {details}"); + } + catch { } + } + + private static string TrimForLog(string s) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + const int max = 500; + if (s.Length <= max) return s; + return s.Substring(0, max) + "...(truncated)"; + } + private bool TerminateProcess(int pid) { try @@ -401,22 +1061,36 @@ private bool TerminateProcess(int pid) if (Application.platform == RuntimePlatform.WindowsEditor) { // taskkill without /F first; fall back to /F if needed. - bool ok = ExecPath.TryRun("taskkill", $"/PID {pid}", Application.dataPath, out stdout, out stderr); + bool ok = ExecPath.TryRun("taskkill", $"/PID {pid} /T", Application.dataPath, out stdout, out stderr); if (!ok) { - ok = ExecPath.TryRun("taskkill", $"/F /PID {pid}", Application.dataPath, out stdout, out stderr); + ok = ExecPath.TryRun("taskkill", $"/F /PID {pid} /T", Application.dataPath, out stdout, out stderr); } return ok; } else { - // Try a graceful termination first, then escalate. - bool ok = ExecPath.TryRun("kill", $"-15 {pid}", Application.dataPath, out stdout, out stderr); - if (!ok) + // 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) { - ok = ExecPath.TryRun("kill", $"-9 {pid}", Application.dataPath, out stdout, out stderr); + if (!ProcessExistsUnix(pid)) + { + return true; + } + System.Threading.Thread.Sleep(100); } - return ok; + + // Escalate. + ExecPath.TryRun(killPath, $"-9 {pid}", Application.dataPath, out stdout, out stderr); + return !ProcessExistsUnix(pid); } } catch (Exception ex) @@ -426,6 +1100,23 @@ private bool TerminateProcess(int pid) } } + private static bool ProcessExistsUnix(int pid) + { + try + { + // 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 stdout, out var stderr, 2000); + string combined = ((stdout ?? string.Empty) + "\n" + (stderr ?? string.Empty)).Trim(); + return !string.IsNullOrEmpty(combined) && combined.Any(char.IsDigit); + } + catch + { + return true; // Assume it exists if we cannot verify. + } + } + /// /// Attempts to build the command used for starting the local HTTP server /// @@ -433,6 +1124,22 @@ public bool TryGetLocalHttpServerCommand(out string command, out string error) { command = null; error = null; + if (!TryGetLocalHttpServerCommandParts(out var fileName, out var args, out var displayCommand, out error)) + { + return false; + } + + // Maintain existing behavior: return a single command string suitable for display/copy. + command = displayCommand; + return true; + } + + private bool TryGetLocalHttpServerCommandParts(out string fileName, out string arguments, out string displayCommand, out string error) + { + fileName = null; + arguments = null; + displayCommand = null; + error = null; bool useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true); if (!useHttpTransport) @@ -463,7 +1170,9 @@ public bool TryGetLocalHttpServerCommand(out string command, out string error) ? $"{devFlags}{packageName} --transport http --http-url {httpUrl}" : $"{devFlags}--from {fromUrl} {packageName} --transport http --http-url {httpUrl}"; - command = $"{uvxPath} {args}"; + fileName = uvxPath; + arguments = args; + displayCommand = $"{QuoteIfNeeded(uvxPath)} {args}"; return true; } @@ -516,13 +1225,21 @@ private System.Diagnostics.ProcessStartInfo CreateTerminalProcessStartInfo(strin command = command.Replace("\r", "").Replace("\n", ""); #if UNITY_EDITOR_OSX - // macOS: Use osascript directly to avoid shell metacharacter injection via bash - // Escape for AppleScript: backslash and double quotes - string escapedCommand = command.Replace("\\", "\\\\").Replace("\"", "\\\""); + // 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/osascript", - Arguments = $"-e \"tell application \\\"Terminal\\\" to do script \\\"{escapedCommand}\\\" activate\"", + FileName = "/usr/bin/open", + Arguments = $"-a Terminal \"{scriptPath}\"", UseShellExecute = false, CreateNoWindow = true }; diff --git a/MCPForUnity/Editor/Windows/Components/Common.uss b/MCPForUnity/Editor/Windows/Components/Common.uss index e89e0bec7..6ef574575 100644 --- a/MCPForUnity/Editor/Windows/Components/Common.uss +++ b/MCPForUnity/Editor/Windows/Components/Common.uss @@ -193,6 +193,24 @@ background-color: rgba(30, 120, 200, 1); } +/* Start Server button in the manual config section should align flush left like other full-width controls */ +.start-server-button { + margin-left: 0px; +} + +/* When the HTTP server/session is running, we show the Start/Stop button as "danger" (red) */ +.action-button.server-running { + background-color: rgba(200, 50, 50, 0.85); +} + +.action-button.server-running:hover { + background-color: rgba(220, 60, 60, 1); +} + +.action-button.server-running:active { + background-color: rgba(170, 40, 40, 1); +} + .secondary-button { width: 100%; height: 28px; @@ -359,7 +377,7 @@ } .tool-parameters { - font-style: italic; + -unity-font-style: italic; } /* Advanced Settings */ diff --git a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs index d6b0db291..75dc94fa2 100644 --- a/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs +++ b/MCPForUnity/Editor/Windows/Components/Connection/McpConnectionSection.cs @@ -1,4 +1,5 @@ using System; +using System.Net.Sockets; using System.Threading.Tasks; using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; @@ -20,7 +21,8 @@ public class McpConnectionSection // Transport protocol enum private enum TransportProtocol { - HTTP, + HTTPLocal, + HTTPRemote, Stdio } @@ -41,6 +43,7 @@ private enum TransportProtocol private Button connectionToggleButton; private VisualElement healthIndicator; private Label healthStatusLabel; + private VisualElement healthRow; private Button testConnectionButton; private bool connectionToggleInProgress; @@ -48,6 +51,8 @@ private enum TransportProtocol private bool httpServerToggleInProgress; private Task verificationTask; private string lastHealthStatus; + private double lastLocalServerRunningPollTime; + private bool lastLocalServerRunning; // Health status constants private const string HealthStatusUnknown = "Unknown"; @@ -87,14 +92,29 @@ private void CacheUIElements() connectionToggleButton = Root.Q