From c79bd10a762e8b06c19813814c1900290d111226 Mon Sep 17 00:00:00 2001 From: Lou Garczynski Date: Mon, 13 Jul 2026 19:05:26 +0000 Subject: [PATCH 1/2] feat(transport): add a headless boot path for the HTTP plugin hub StartStdioForCi forces stdio and only listens. The HTTP transport is a pull model where the editor dials the hub, so an editor booted for CI over HTTP never registers and every tool call returns no_unity_session against a healthy editor. StartHttpForCi dials the hub instead, on the port in UNITY_MCP_HTTP_PORT. The port comes from the env rather than a pref because sibling checkouts of one project share an EditorPrefs file and would clobber each other's endpoint. --- MCPForUnity/Editor/McpHttpCiBoot.cs | 57 ++++++++++++++++++++++++ MCPForUnity/Editor/McpHttpCiBoot.cs.meta | 11 +++++ 2 files changed, 68 insertions(+) create mode 100644 MCPForUnity/Editor/McpHttpCiBoot.cs create mode 100644 MCPForUnity/Editor/McpHttpCiBoot.cs.meta diff --git a/MCPForUnity/Editor/McpHttpCiBoot.cs b/MCPForUnity/Editor/McpHttpCiBoot.cs new file mode 100644 index 000000000..21ddadbfc --- /dev/null +++ b/MCPForUnity/Editor/McpHttpCiBoot.cs @@ -0,0 +1,57 @@ +using System; +using MCPForUnity.Editor.Constants; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Services.Transport; +using UnityEditor; + +namespace MCPForUnity.Editor +{ + // HTTP is a plugin-hub pull model: the bridge waits at ws://127.0.0.1:/hub/plugin + // and the editor must dial in. StartStdioForCi forces stdio and only listens, so an + // editor booted that way never registers and every call returns no_unity_session. + // + // The port comes from UNITY_MCP_HTTP_PORT rather than a pref because sibling checkouts + // of one project share an EditorPrefs file, so a persisted HttpUrl is clobbered by + // whichever editor wrote last. + public static class McpHttpCiBoot + { + private const string PortEnv = "UNITY_MCP_HTTP_PORT"; + + // Re-asserted on every domain load so a reload cannot resume a sibling's port + [InitializeOnLoadMethod] + private static void ReassertEndpointFromEnv() + { + ApplyEndpointFromEnv(); + } + + public static void StartHttpForCi() + { + if (!ApplyEndpointFromEnv()) + { + McpLog.Error($"[MCPForUnity] StartHttpForCi: {PortEnv} not set or invalid; cannot start HTTP transport"); + return; + } + + // Defer the connect so MCPServiceLocator and friends are initialized. + EditorApplication.delayCall += () => + { + _ = MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http); + }; + } + + private static bool ApplyEndpointFromEnv() + { + string portStr = Environment.GetEnvironmentVariable(PortEnv); + if (string.IsNullOrWhiteSpace(portStr) || !int.TryParse(portStr, out int port) || port <= 0) + { + return false; + } + + EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true); + EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, "local"); + EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, $"http://127.0.0.1:{port}"); + return true; + } + } +} diff --git a/MCPForUnity/Editor/McpHttpCiBoot.cs.meta b/MCPForUnity/Editor/McpHttpCiBoot.cs.meta new file mode 100644 index 000000000..1bd4658ac --- /dev/null +++ b/MCPForUnity/Editor/McpHttpCiBoot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3fff0044fbf5420d8be6c5bb79828276 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 7e810768e206e3ac9187baaad44b47b40250b41c Mon Sep 17 00:00:00 2001 From: Lou Garczynski Date: Wed, 2 Sep 2026 13:48:12 +0000 Subject: [PATCH 2/2] fix(ci-boot): keep the CI endpoint out of shared EditorPrefs, and report start failures Sibling checkouts of one project share an EditorPrefs file, so persisting the port let whichever editor wrote last clobber the others, and an interactive editor could resume a stale CI endpoint. UNITY_MCP_HTTP_PORT is now read live at the endpoint choke point and never stored, which is per-process by construction. Also await StartAsync so a transport that never starts is logged instead of leaving CI to fail later with no_unity_session, and reject ports above 65535. --- .../Editor/Helpers/HttpEndpointUtility.cs | 8 ++- MCPForUnity/Editor/McpHttpCiBoot.cs | 50 ++++++++----------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs index 94e2f3be9..8678676d8 100644 --- a/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs +++ b/MCPForUnity/Editor/Helpers/HttpEndpointUtility.cs @@ -47,10 +47,13 @@ public static void SaveBaseUrl(string userValue) } /// - /// Returns the normalized local HTTP base URL (always reads local pref). + /// Returns the normalized local HTTP base URL. UNITY_MCP_HTTP_PORT wins when set, + /// so a CI editor keeps its own port instead of the one a sibling last wrote. /// public static string GetLocalBaseUrl() { + if (McpHttpCiBoot.TryGetCiPort(out int ciPort)) return $"http://127.0.0.1:{ciPort}"; + string stored = EditorPrefs.GetString(LocalPrefKey, DefaultLocalBaseUrl); return NormalizeBaseUrl(stored, DefaultLocalBaseUrl, remoteScope: false); } @@ -128,9 +131,12 @@ public static string GetRegisterToolsUrl() /// /// Returns true if the active HTTP transport scope is "remote". + /// Always false under UNITY_MCP_HTTP_PORT: the CI endpoint is local by construction. /// public static bool IsRemoteScope() { + if (McpHttpCiBoot.TryGetCiPort(out _)) return false; + string scope = EditorConfigurationCache.Instance.HttpTransportScope; return string.Equals(scope, "remote", StringComparison.OrdinalIgnoreCase); } diff --git a/MCPForUnity/Editor/McpHttpCiBoot.cs b/MCPForUnity/Editor/McpHttpCiBoot.cs index 21ddadbfc..6366ac501 100644 --- a/MCPForUnity/Editor/McpHttpCiBoot.cs +++ b/MCPForUnity/Editor/McpHttpCiBoot.cs @@ -1,5 +1,4 @@ using System; -using MCPForUnity.Editor.Constants; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Services; using MCPForUnity.Editor.Services.Transport; @@ -7,51 +6,44 @@ namespace MCPForUnity.Editor { - // HTTP is a plugin-hub pull model: the bridge waits at ws://127.0.0.1:/hub/plugin - // and the editor must dial in. StartStdioForCi forces stdio and only listens, so an - // editor booted that way never registers and every call returns no_unity_session. - // - // The port comes from UNITY_MCP_HTTP_PORT rather than a pref because sibling checkouts - // of one project share an EditorPrefs file, so a persisted HttpUrl is clobbered by - // whichever editor wrote last. + // HTTP is a plugin-hub pull model: the editor dials ws://127.0.0.1:/hub/plugin + // StartStdioForCi only listens, so an editor booted that way never registers public static class McpHttpCiBoot { private const string PortEnv = "UNITY_MCP_HTTP_PORT"; - // Re-asserted on every domain load so a reload cannot resume a sibling's port - [InitializeOnLoadMethod] - private static void ReassertEndpointFromEnv() + // Never persisted: sibling checkouts share one EditorPrefs file, so the last writer wins + public static bool TryGetCiPort(out int port) { - ApplyEndpointFromEnv(); + port = 0; + string raw = Environment.GetEnvironmentVariable(PortEnv); + return !string.IsNullOrWhiteSpace(raw) + && int.TryParse(raw, out port) + && port > 0 + && port <= 65535; } public static void StartHttpForCi() { - if (!ApplyEndpointFromEnv()) + if (!TryGetCiPort(out _)) { McpLog.Error($"[MCPForUnity] StartHttpForCi: {PortEnv} not set or invalid; cannot start HTTP transport"); return; } // Defer the connect so MCPServiceLocator and friends are initialized. - EditorApplication.delayCall += () => + EditorApplication.delayCall += async () => { - _ = MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http); + try + { + if (!await MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http)) + McpLog.Error("[MCPForUnity] StartHttpForCi: HTTP transport failed to start"); + } + catch (Exception e) + { + McpLog.Error($"[MCPForUnity] StartHttpForCi: HTTP transport threw on start: {e}"); + } }; } - - private static bool ApplyEndpointFromEnv() - { - string portStr = Environment.GetEnvironmentVariable(PortEnv); - if (string.IsNullOrWhiteSpace(portStr) || !int.TryParse(portStr, out int port) || port <= 0) - { - return false; - } - - EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, true); - EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, "local"); - EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, $"http://127.0.0.1:{port}"); - return true; - } } }