From 9c1f662e1bd8205f6815da17c038c7745d3567c2 Mon Sep 17 00:00:00 2001 From: Daliys Date: Sun, 5 Jul 2026 21:06:35 +0200 Subject: [PATCH] fix: require auth for local MCP API --- API_REFERENCE.MD | 3 +- CHANGELOG.md | 1 + DOCUMENTATION.MD | 3 +- Editor/MCPCliInstaller.ClaudeCode.cs | 5 +- Editor/MCPCliInstaller.Codex.cs | 78 ++----------------- Editor/MCPCliInstaller.Gemini.cs | 2 +- Editor/MCPServer.Networking.cs | 31 ++++++++ Editor/MCPServer.cs | 22 ++++++ Editor/NexusMcpConfigGenerator.cs | 31 ++++++-- Editor/nexus_bridge/_transport.py | 6 +- Editor/tests/test_transport.py | 11 +++ README.md | 15 +++- Tests~/Editor/NexusMcpConfigGeneratorTests.cs | 32 ++++++++ Tests~/Editor/ServerPortTests.cs | 11 +++ 14 files changed, 166 insertions(+), 85 deletions(-) diff --git a/API_REFERENCE.MD b/API_REFERENCE.MD index 50cec84..7a54946 100644 --- a/API_REFERENCE.MD +++ b/API_REFERENCE.MD @@ -14,10 +14,11 @@ Raw requests use unprefixed method names: ```bash curl -s http://127.0.0.1:8081/ \ -H 'Content-Type: application/json' \ + -H "X-Nexus-Unity-Token: $NEXUS_UNITY_AUTH_TOKEN" \ -d '{"jsonrpc":"2.0","method":"get_server_status","params":{},"id":1}' ``` -Call `list_tools` at runtime for full JSON schemas. MCP bridge aliases use the `unity_` prefix for the same raw method when directly routed. +Call `list_tools` at runtime for full JSON schemas. Generated MCP configs set `NEXUS_UNITY_AUTH_TOKEN` for the Python bridge; rerun Auto Setup after restarting Unity if the token is stale. MCP bridge aliases use the `unity_` prefix for the same raw method when directly routed. Schema compatibility notes: diff --git a/CHANGELOG.md b/CHANGELOG.md index a94ee51..13b9764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable public changes to Nexus Unity are documented here. - Relicensed Nexus Unity from `GPL-3.0-only` to the `MIT` license to remove copyleft friction for commercial Unity studios. All prior contributors consented to the relicense. ### Fixed +- The local HTTP/WebSocket control plane now requires a per-session auth token before JSON-RPC dispatch; generated MCP configs pass the token through the Python bridge. - `add_component` now returns a clear `GameObject not found` error for stale instance IDs instead of throwing a raw Unity null reference. - `batch_execute` now caps batches at 50 requests and rejects nested batch execution. - `create_primitive` now validates parent, transform, and material inputs before creating the GameObject, and Vector3 inputs accept `[x, y, z]` arrays as well as `{x, y, z}` objects. diff --git a/DOCUMENTATION.MD b/DOCUMENTATION.MD index 7531275..9972bba 100644 --- a/DOCUMENTATION.MD +++ b/DOCUMENTATION.MD @@ -59,6 +59,7 @@ When Nexus Unity writes an existing user or project config, it preserves unrelat Nexus Unity is a local developer tool and should be used only with trusted local clients. - HTTP and WebSocket requests must target loopback hosts. +- HTTP and WebSocket requests require the per-session `X-Nexus-Unity-Token` header; generated MCP configs pass it to the Python bridge as `NEXUS_UNITY_AUTH_TOKEN`. - Browser origins are validated to reduce CSRF and DNS rebinding exposure; non-loopback origins are rejected. - File operations resolve paths and enforce the Unity project root boundary. - C# script writes require `confirm: true` before creating or overwriting `.cs` files and triggering Unity compilation. @@ -80,7 +81,7 @@ Manual or root-deployed path: python3 nexus_unity_bridge.py ``` -Bridge connection settings can be overridden with `NEXUS_UNITY_URL`, `NEXUS_UNITY_PORT`, and `NEXUS_UNITY_TIMEOUT_SECONDS`. A positional port argument is still supported for existing MCP client configs, and invalid port values fall back to the positional port or default port. Bridge logging defaults to `DEBUG` and can be changed with `NEXUS_UNITY_LOG_LEVEL`. +Bridge connection settings can be overridden with `NEXUS_UNITY_URL`, `NEXUS_UNITY_PORT`, `NEXUS_UNITY_TIMEOUT_SECONDS`, and `NEXUS_UNITY_AUTH_TOKEN`. A positional port argument is still supported for existing MCP client configs, and invalid port values fall back to the positional port or default port. Bridge logging defaults to `DEBUG` and can be changed with `NEXUS_UNITY_LOG_LEVEL`. The bridge is modular: diff --git a/Editor/MCPCliInstaller.ClaudeCode.cs b/Editor/MCPCliInstaller.ClaudeCode.cs index b68be4d..eab7a03 100644 --- a/Editor/MCPCliInstaller.ClaudeCode.cs +++ b/Editor/MCPCliInstaller.ClaudeCode.cs @@ -40,7 +40,7 @@ private static void ExecuteClaudeCodeLinkSequence(string scriptPath, string pyth RunInstallerProcess(CreateProcessStartInfo(removeCommand), claudePath, false, "Claude Code"); // 2. Add the server at project scope (silent, because we have a file fallback). - string addCommand = "\"" + claudePath + "\" mcp add nexus-unity -s project -- \"" + pythonPath + "\" \"" + scriptPath + "\""; + string addCommand = "\"" + claudePath + "\" mcp add nexus-unity -s project -e " + MCPServer.AuthTokenEnvironmentVariable + "=" + MCPServer.AuthToken + " -- \"" + pythonPath + "\" \"" + scriptPath + "\""; if (RunInstallerProcess(CreateProcessStartInfo(addCommand), claudePath, false, "Claude Code")) { NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Successfully linked Nexus Unity to Claude Code via '" + claudePath + "'.", true); @@ -74,7 +74,8 @@ private static void ExecuteClaudeCodeLinkSequence(string scriptPath, string pyth servers["nexus-unity"] = new JObject { ["command"] = pythonPath.Replace("\\", "/"), - ["args"] = new JArray { scriptPath.Replace("\\", "/") } + ["args"] = new JArray { scriptPath.Replace("\\", "/") }, + ["env"] = new JObject { [MCPServer.AuthTokenEnvironmentVariable] = MCPServer.AuthToken } }; NexusMcpConfigGenerator.BackupFileIfExists(configPath); diff --git a/Editor/MCPCliInstaller.Codex.cs b/Editor/MCPCliInstaller.Codex.cs index aba4700..64ab839 100644 --- a/Editor/MCPCliInstaller.Codex.cs +++ b/Editor/MCPCliInstaller.Codex.cs @@ -2,7 +2,7 @@ using UnityEngine; using System.IO; using System.Diagnostics; -using System.Collections.Generic; +using System.Linq; using System; namespace UnityMCP.Editor @@ -37,7 +37,7 @@ private static void ExecuteCodexLinkSequence(string scriptPath, string pythonPat RunInstallerProcess(CreateProcessStartInfo(removeCommand), codexPath, false, "Codex"); // 2. Add new with command/args (silent, because we have a fallback) - string addCommand = "\"" + codexPath + "\" mcp add nexus-unity -- \"" + pythonPath + "\" \"" + scriptPath + "\""; + string addCommand = "\"" + codexPath + "\" mcp add nexus-unity --env " + MCPServer.AuthTokenEnvironmentVariable + "=" + MCPServer.AuthToken + " -- \"" + pythonPath + "\" \"" + scriptPath + "\""; // If it succeeds, we are done if (RunInstallerProcess(CreateProcessStartInfo(addCommand), codexPath, false, "Codex")) @@ -55,74 +55,12 @@ private static void ExecuteCodexLinkSequence(string scriptPath, string pythonPat try { string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - string codexDir = Path.Combine(homeDir, ".codex"); - string configPath = Path.Combine(codexDir, "config.toml"); - - if (!Directory.Exists(codexDir)) - { - Directory.CreateDirectory(codexDir); - } - - List lines = new List(); - if (File.Exists(configPath)) - { - lines.AddRange(File.ReadAllLines(configPath)); - } - - string safeScriptPath = scriptPath.Replace("\\", "/"); - string safePythonPath = pythonPath.Replace("\\", "/"); - - // Check if [mcp_servers.nexus-unity] already exists - int existingIndex = -1; - for (int i = 0; i < lines.Count; i++) - { - if (lines[i].Trim() == "[mcp_servers.nexus-unity]") - { - existingIndex = i; - break; - } - } - - if (existingIndex != -1) - { - // Update existing - bool foundCommand = false; - bool foundArgs = false; - for (int i = existingIndex + 1; i < lines.Count; i++) - { - string trimmed = lines[i].Trim(); - if (trimmed.StartsWith("[")) break; // Next section - - if (trimmed.StartsWith("command")) - { - lines[i] = "command = \"" + safePythonPath + "\""; - foundCommand = true; - } - else if (trimmed.StartsWith("args")) - { - lines[i] = "args = [ \"" + safeScriptPath + "\" ]"; - foundArgs = true; - } - } - - if (!foundCommand) lines.Insert(existingIndex + 1, "command = \"" + safePythonPath + "\""); - if (!foundArgs) lines.Insert(existingIndex + (foundCommand ? 2 : 1), "args = [ \"" + safeScriptPath + "\" ]"); - } - else - { - // Append new - if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[lines.Count - 1])) - { - lines.Add(""); // Add a blank line for spacing - } - lines.Add("[mcp_servers.nexus-unity]"); - lines.Add("command = \"" + safePythonPath + "\""); - lines.Add("args = [ \"" + safeScriptPath + "\" ]"); - } - - NexusMcpConfigGenerator.BackupFileIfExists(configPath); - File.WriteAllLines(configPath, lines); - NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Successfully linked Nexus Unity to Codex CLI via manual TOML update: " + configPath, true); + string projectRoot = Path.GetDirectoryName(Application.dataPath); + var codex = NexusMcpConfigGenerator.BuildAll(scriptPath, pythonPath, projectRoot, homeDir) + .First(client => client.Kind == NexusMcpClientKind.Codex); + var result = NexusMcpConfigGenerator.WriteConfig(codex); + if (!result.Success) throw new Exception(result.Message); + NexusEditorLog.Log(NexusLogCategory.Integrations, "[MCP] Successfully linked Nexus Unity to Codex CLI via manual TOML update: " + result.ConfigPath, true); EditorUtility.DisplayDialog("MCP Success", "Successfully linked Nexus Unity to your system Codex CLI!", "OK"); } catch (Exception e) diff --git a/Editor/MCPCliInstaller.Gemini.cs b/Editor/MCPCliInstaller.Gemini.cs index 490a469..735e4c5 100644 --- a/Editor/MCPCliInstaller.Gemini.cs +++ b/Editor/MCPCliInstaller.Gemini.cs @@ -34,7 +34,7 @@ private static void ExecuteGeminiLinkSequence(string scriptPath) RunInstallerProcess(CreateProcessStartInfo(removeCommand), geminiPath, false, "Gemini"); // 2. Add new registration with stable path - string addCommand = "\"" + geminiPath + "\" mcp add nexus-unity --trust \"" + pythonPath + "\" \"" + scriptPath + "\""; + string addCommand = "\"" + geminiPath + "\" mcp add nexus-unity --trust -e " + MCPServer.AuthTokenEnvironmentVariable + "=" + MCPServer.AuthToken + " \"" + pythonPath + "\" \"" + scriptPath + "\""; RunInstallerProcess(CreateProcessStartInfo(addCommand), geminiPath, true, "Gemini"); } } diff --git a/Editor/MCPServer.Networking.cs b/Editor/MCPServer.Networking.cs index bc3aaed..fd3acc0 100644 --- a/Editor/MCPServer.Networking.cs +++ b/Editor/MCPServer.Networking.cs @@ -17,10 +17,12 @@ private static async Task IsAnotherMcpInstanceRunning() { using var client = new System.Net.Http.HttpClient(); client.Timeout = TimeSpan.FromMilliseconds(500); + client.DefaultRequestHeaders.Add(AuthTokenHeaderName, AuthToken); try { var content = new System.Net.Http.StringContent("{\"jsonrpc\":\"2.0\",\"method\":\"get_server_status\",\"params\":{},\"id\":1}", Encoding.UTF8, "application/json"); var response = await client.PostAsync($"http://127.0.0.1:{_port}/", content); + if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) return true; string body = await response.Content.ReadAsStringAsync(); if (body.Contains("serverAlive")) @@ -58,6 +60,7 @@ private static async Task IsAnotherMcpInstanceRunning() // Fallback to old initialize method just in case it's an older server var initContent = new System.Net.Http.StringContent("{\"jsonrpc\":\"2.0\",\"method\":\"initialize\",\"params\":{},\"id\":1}", Encoding.UTF8, "application/json"); var initResponse = await client.PostAsync($"http://127.0.0.1:{_port}/", initContent); + if (initResponse.StatusCode == HttpStatusCode.Unauthorized || initResponse.StatusCode == HttpStatusCode.Forbidden) return true; string initBody = await initResponse.Content.ReadAsStringAsync(); if (initBody.Contains("Unity MCP Server")) { NexusEditorLog.Log(NexusLogCategory.Server, $"[MCP] Connected to an older existing session on port {_port}", true); @@ -139,6 +142,12 @@ private static async Task ProcessWebSocket(HttpListenerContext context) return; } + if (!IsAuthorized(context)) + { + RejectUnauthorized(context); + return; + } + var wsContext = await context.AcceptWebSocketAsync(null); _webSocket = wsContext.WebSocket; await ReceiveWebsocketLoop(_cts.Token); @@ -154,6 +163,12 @@ private static void HandleHttpRequest(HttpListenerContext context) return; } + if (!IsAuthorized(context)) + { + RejectUnauthorized(context); + return; + } + if (context.Request.HttpMethod != "POST") { context.Response.StatusCode = (int)HttpStatusCode.MethodNotAllowed; context.Response.Close(); @@ -205,6 +220,22 @@ private static void HandleHttpRequest(HttpListenerContext context) } } + private static bool IsAuthorized(HttpListenerContext context) + { + return IsAuthorizedToken(context.Request.Headers[AuthTokenHeaderName]); + } + + internal static bool IsAuthorizedToken(string token) + { + return !string.IsNullOrEmpty(token) && string.Equals(token, _authToken, StringComparison.Ordinal); + } + + private static void RejectUnauthorized(HttpListenerContext context) + { + context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + context.Response.Close(); + } + private static async Task ReceiveWebsocketLoop(CancellationToken token) { var buffer = new byte[4096]; diff --git a/Editor/MCPServer.cs b/Editor/MCPServer.cs index d86a886..86e9b47 100644 --- a/Editor/MCPServer.cs +++ b/Editor/MCPServer.cs @@ -31,6 +31,9 @@ public static partial class MCPServer public static string SessionId { get; private set; } public static int SessionGeneration { get; private set; } + internal const string AuthTokenEnvironmentVariable = "NEXUS_UNITY_AUTH_TOKEN"; + internal const string AuthTokenHeaderName = "X-Nexus-Unity-Token"; + private static string _authToken; public static DateTime LastMainThreadTickUtc { get; private set; } public static bool IsCompilingCached { get; private set; } public static bool IsUpdatingCached { get; private set; } @@ -61,6 +64,7 @@ private static string GetDeterministicProjectKey() } private static string _prefsKeyCached; private static string StablePrefsKey => _prefsKeyCached ?? (_prefsKeyCached = GetDeterministicProjectKey()); + private static string AuthSessionStateKey => StablePrefsKey + "_AuthToken"; private static int _mainThreadId = -1; public static int MainThreadId => _mainThreadId; @@ -84,6 +88,22 @@ static MCPServer() _logs = new ConcurrentQueue(); } + internal static string AuthToken => EnsureAuthToken(); + + private static string EnsureAuthToken() + { + if (!string.IsNullOrEmpty(_authToken)) return _authToken; + + _authToken = SessionState.GetString(AuthSessionStateKey, string.Empty); + if (string.IsNullOrEmpty(_authToken)) + { + _authToken = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); + SessionState.SetString(AuthSessionStateKey, _authToken); + } + + return _authToken; + } + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void RuntimeInit() => Init(); @@ -100,6 +120,7 @@ internal static void Init() SessionState.SetString("MCP_SessionId", SessionId); SessionGeneration = SessionState.GetInt("MCP_SessionGen", 0) + 1; SessionState.SetInt("MCP_SessionGen", SessionGeneration); + EnsureAuthToken(); } RefreshMainThreadCachedState(); @@ -188,6 +209,7 @@ public static void Start() } MCPServerMethods.Init(); + EnsureAuthToken(); if (EditorApplication.isPlaying) Application.runInBackground = true; if (_port <= 0) { diff --git a/Editor/NexusMcpConfigGenerator.cs b/Editor/NexusMcpConfigGenerator.cs index fa80b98..9a57664 100644 --- a/Editor/NexusMcpConfigGenerator.cs +++ b/Editor/NexusMcpConfigGenerator.cs @@ -120,7 +120,8 @@ internal static string BuildCodexToml(string bridgePath, string pythonPath) { return "[mcp_servers.nexus-unity]\n" + "command = \"" + EscapeToml(NormalizePath(pythonPath)) + "\"\n" + - "args = [ \"" + EscapeToml(NormalizePath(bridgePath)) + "\" ]\n"; + "args = [ \"" + EscapeToml(NormalizePath(bridgePath)) + "\" ]\n" + + "env = { " + MCPServer.AuthTokenEnvironmentVariable + " = \"" + EscapeToml(MCPServer.AuthToken) + "\" }\n"; } internal static string BackupFileIfExists(string path) @@ -245,6 +246,11 @@ private static void ApplyTomlStatus(NexusMcpClientInfo info, string executable) info.Status = NexusMcpClientStatus.Configured; info.StatusDetail = "nexus-unity is present in the config."; + if (!TomlHasCurrentAuthToken(lines)) + { + info.Status = NexusMcpClientStatus.Outdated; + info.StatusDetail = "nexus-unity is missing the current auth token. Run Auto Setup, then restart this client."; + } return; } } @@ -302,6 +308,11 @@ private static void ApplyJsonStatus(NexusMcpClientInfo info) info.Status = NexusMcpClientStatus.Configured; info.StatusDetail = "nexus-unity is present in the config."; + if (!JsonHasCurrentAuthToken(server)) + { + info.Status = NexusMcpClientStatus.Outdated; + info.StatusDetail = "nexus-unity is missing the current auth token. Run Auto Setup, then restart this client."; + } } else { @@ -368,11 +379,21 @@ private static void RemoveTomlSection(List lines) private static JObject BuildServerEntry(string bridgePath, string pythonPath) { - return new JObject + return new JObject { ["command"] = NormalizePath(pythonPath), ["args"] = new JArray { NormalizePath(bridgePath) }, ["env"] = new JObject { [MCPServer.AuthTokenEnvironmentVariable] = MCPServer.AuthToken } }; + } + + private static bool JsonHasCurrentAuthToken(JObject server) => string.Equals((string)server["env"]?[MCPServer.AuthTokenEnvironmentVariable], MCPServer.AuthToken, StringComparison.Ordinal); + + private static bool TomlHasCurrentAuthToken(List lines) + { + string needle = MCPServer.AuthTokenEnvironmentVariable + " = \"" + MCPServer.AuthToken + "\""; + int start = lines.FindIndex(line => line.Trim() == "[mcp_servers.nexus-unity]"); + if (start < 0) return false; + for (int i = start + 1; i < lines.Count && !lines[i].TrimStart().StartsWith("["); i++) { - ["command"] = NormalizePath(pythonPath), - ["args"] = new JArray { NormalizePath(bridgePath) } - }; + if (lines[i].Contains(needle)) return true; + } + return false; } private static bool IsExecutableResolved(string name) diff --git a/Editor/nexus_bridge/_transport.py b/Editor/nexus_bridge/_transport.py index 4004e9e..95fb841 100644 --- a/Editor/nexus_bridge/_transport.py +++ b/Editor/nexus_bridge/_transport.py @@ -49,15 +49,19 @@ def _read_timeout() -> float: else f"http://127.0.0.1:{_read_port()}/" ) UNITY_TIMEOUT_SECONDS: float = _read_timeout() +AUTH_TOKEN: str | None = os.environ.get("NEXUS_UNITY_AUTH_TOKEN") def call_unity(method: str, params: JsonObject | None = None) -> JsonRpcResponse: payload = {"jsonrpc": "2.0", "method": method, "params": params or {}, "id": 1} data: bytes = json.dumps(payload).encode("utf-8") + headers: dict[str, str] = {"Content-Type": "application/json"} + if AUTH_TOKEN: + headers["X-Nexus-Unity-Token"] = AUTH_TOKEN req: urllib.request.Request = urllib.request.Request( UNITY_URL, data=data, - headers={"Content-Type": "application/json"}, + headers=headers, ) try: with urllib.request.urlopen(req, timeout=UNITY_TIMEOUT_SECONDS) as response: diff --git a/Editor/tests/test_transport.py b/Editor/tests/test_transport.py index cddd04c..eb38d4f 100644 --- a/Editor/tests/test_transport.py +++ b/Editor/tests/test_transport.py @@ -83,6 +83,17 @@ def test_call_unity_sends_expected_json_rpc_payload(self) -> None: payload, ) + def test_call_unity_sends_auth_token_header_when_configured(self) -> None: + transport: Any = _reload_transport_module({"NEXUS_UNITY_AUTH_TOKEN": "secret-token"}, ["nexus_unity_bridge.py"]) + fake_response: _FakeHttpResponse = _FakeHttpResponse(b'{"result": {"ok": true}}') + + with patch("nexus_bridge._transport.urllib.request.urlopen", return_value=fake_response) as mock_urlopen: + transport.call_unity("get_state") + + request: Any = mock_urlopen.call_args.args[0] + headers: dict[str, str] = {key.lower(): value for key, value in request.header_items()} + self.assertEqual("secret-token", headers["x-nexus-unity-token"]) + class ReadPortTests(unittest.TestCase): def test_read_port_prefers_environment_variable(self) -> None: diff --git a/README.md b/README.md index 702ce9d..a748f21 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Nexus Unity does not declare Unity Project Auditor packages. Its lint tool alway http://127.0.0.1:8081/ ``` -The server is intended for trusted local automation only. It validates loopback hosts and browser origins, constrains file operations to the Unity project root, and limits request payload size. +The server is intended for trusted local automation only. It validates loopback hosts and browser origins, requires the per-session `X-Nexus-Unity-Token` header generated by the Integrations setup flow, constrains file operations to the Unity project root, and limits request payload size. ## Editor Menu @@ -98,6 +98,7 @@ Direct JSON-RPC example: ```bash curl -s http://127.0.0.1:8081/ \ -H 'Content-Type: application/json' \ + -H "X-Nexus-Unity-Token: $NEXUS_UNITY_AUTH_TOKEN" \ -d '{"jsonrpc":"2.0","method":"get_server_status","params":{},"id":1}' ``` @@ -125,7 +126,7 @@ Nexus Unity generates configs from the resolved Python interpreter and the deplo > **Windows / Linux:** The Python interpreter is resolved as `python3`, then `python`, then the Windows `py` launcher — make sure a Python 3 install is on `PATH`. CLI-based cards (Codex, Gemini, and the Claude Code CLI path) are detected with `where` on Windows and `which` on macOS/Linux, so the CLI must be on the `PATH` of the shell that launched Unity for `Auto Setup` to find it; otherwise use `Copy Config`. -`Outdated` means the client config points at a different Unity project, the project-root bridge has not been deployed, or the deployed bridge version differs from the package bridge version. Run `Auto Setup`, then restart the affected MCP client session so it loads the current bridge. +`Outdated` means the client config points at a different Unity project, the project-root bridge has not been deployed, the auth token is missing or stale, or the deployed bridge version differs from the package bridge version. Run `Auto Setup`, then restart the affected MCP client session so it loads the current bridge. Package path: @@ -134,7 +135,10 @@ Package path: "mcpServers": { "nexus-unity": { "command": "python3", - "args": ["Packages/com.forkhorizon.nexus.unity/Editor/nexus_unity_bridge.py"] + "args": ["Packages/com.forkhorizon.nexus.unity/Editor/nexus_unity_bridge.py"], + "env": { + "NEXUS_UNITY_AUTH_TOKEN": "" + } } } } @@ -147,7 +151,10 @@ Root-deployed path: "mcpServers": { "nexus-unity": { "command": "python3", - "args": ["nexus_unity_bridge.py"] + "args": ["nexus_unity_bridge.py"], + "env": { + "NEXUS_UNITY_AUTH_TOKEN": "" + } } } } diff --git a/Tests~/Editor/NexusMcpConfigGeneratorTests.cs b/Tests~/Editor/NexusMcpConfigGeneratorTests.cs index 45b7ca1..1ffe0f0 100644 --- a/Tests~/Editor/NexusMcpConfigGeneratorTests.cs +++ b/Tests~/Editor/NexusMcpConfigGeneratorTests.cs @@ -20,6 +20,10 @@ public void BuildsExpectedConfigSnippets() Assert.IsTrue(vsCode.Contains("\"servers\"")); Assert.IsTrue(codex.Contains("[mcp_servers.nexus-unity]")); Assert.IsTrue(codex.Contains("command = \"/usr/bin/python3\"")); + + var jsonRoot = JObject.Parse(json); + Assert.AreEqual(MCPServer.AuthToken, (string)jsonRoot["mcpServers"]["nexus-unity"]["env"][MCPServer.AuthTokenEnvironmentVariable]); + Assert.IsTrue(codex.Contains(MCPServer.AuthTokenEnvironmentVariable + " = \"" + MCPServer.AuthToken + "\"")); } [Test] @@ -100,6 +104,34 @@ public void DetectsConfiguredJsonServer() } } + [Test] + public void DetectsJsonServerMissingCurrentAuthToken() + { + string root = CreateTempRoot(); + try + { + string projectRoot = Path.Combine(root, "Project"); + string homeRoot = Path.Combine(root, "Home"); + string sourceBridge = Path.Combine(root, "Package", "nexus_unity_bridge.py"); + string deployedBridge = Path.Combine(projectRoot, "nexus_unity_bridge.py"); + WriteBridge(sourceBridge, "1.2.0"); + WriteBridge(deployedBridge, "1.2.0"); + string configPath = Path.Combine(projectRoot, ".cursor", "mcp.json"); + Directory.CreateDirectory(Path.GetDirectoryName(configPath)); + File.WriteAllText(configPath, "{ \"mcpServers\": { \"nexus-unity\": { \"command\": \"/python3\", \"args\": [\"" + deployedBridge.Replace("\\", "/") + "\"] } } }"); + + var configured = NexusMcpConfigGenerator.BuildAll(deployedBridge, "/python3", projectRoot, homeRoot, sourceBridge) + .First(item => item.Kind == NexusMcpClientKind.Cursor); + + Assert.AreEqual(NexusMcpClientStatus.Outdated, configured.Status); + StringAssert.Contains("auth token", configured.StatusDetail); + } + finally + { + DeleteTempRoot(root); + } + } + [Test] public void DetectsOutdatedDeployedBridgeVersion() { diff --git a/Tests~/Editor/ServerPortTests.cs b/Tests~/Editor/ServerPortTests.cs index 464e709..3de66a5 100644 --- a/Tests~/Editor/ServerPortTests.cs +++ b/Tests~/Editor/ServerPortTests.cs @@ -47,6 +47,17 @@ public void IsPortBusy_DetectsLocalListener() } } + [Test] + public void AuthToken_IsRequiredForNetworkRequests() + { + string token = MCPServer.AuthToken; + + Assert.IsFalse((bool)InvokePrivateMethod("IsAuthorizedToken", null)); + Assert.IsFalse((bool)InvokePrivateMethod("IsAuthorizedToken", "")); + Assert.IsFalse((bool)InvokePrivateMethod("IsAuthorizedToken", "wrong-token")); + Assert.IsTrue((bool)InvokePrivateMethod("IsAuthorizedToken", token)); + } + [Test] public void GetPortOwner_IdentifiesProcess() {