Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion API_REFERENCE.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion DOCUMENTATION.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:

Expand Down
5 changes: 3 additions & 2 deletions Editor/MCPCliInstaller.ClaudeCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
}
}

private static void ExecuteClaudeCodeLinkSequence(string scriptPath, string pythonPath)

Check warning on line 31 in Editor/MCPCliInstaller.ClaudeCode.cs

View workflow job for this annotation

GitHub Actions / Static validation

NQG012 ExecuteClaudeCodeLinkSequence

Method has 62 lines; consider splitting before it exceeds 150.

Check warning on line 31 in Editor/MCPCliInstaller.ClaudeCode.cs

View workflow job for this annotation

GitHub Actions / Documentation quality AI

NQG012 ExecuteClaudeCodeLinkSequence

Method has 62 lines; consider splitting before it exceeds 150.
{
string claudePath = ResolveExecutablePath("claude");

Expand All @@ -40,7 +40,7 @@
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);
Expand Down Expand Up @@ -74,7 +74,8 @@
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);
Expand Down
78 changes: 8 additions & 70 deletions Editor/MCPCliInstaller.Codex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
using UnityEngine;
using System.IO;
using System.Diagnostics;
using System.Collections.Generic;
using System.Linq;
using System;

namespace UnityMCP.Editor
Expand Down Expand Up @@ -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"))
Expand All @@ -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<string> lines = new List<string>();
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)
Expand Down
2 changes: 1 addition & 1 deletion Editor/MCPCliInstaller.Gemini.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Expand Down
31 changes: 31 additions & 0 deletions Editor/MCPServer.Networking.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@
{
public static partial class MCPServer
{
private static async Task<bool> IsAnotherMcpInstanceRunning()

Check warning on line 16 in Editor/MCPServer.Networking.cs

View workflow job for this annotation

GitHub Actions / Static validation

NQG012 IsAnotherMcpInstanceRunning

Method has 58 lines; consider splitting before it exceeds 150.

Check warning on line 16 in Editor/MCPServer.Networking.cs

View workflow job for this annotation

GitHub Actions / Documentation quality AI

NQG012 IsAnotherMcpInstanceRunning

Method has 58 lines; consider splitting before it exceeds 150.
{
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"))
Expand Down Expand Up @@ -58,6 +60,7 @@
// 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);
Expand Down Expand Up @@ -139,12 +142,18 @@
return;
}

if (!IsAuthorized(context))
{
RejectUnauthorized(context);
return;
}

var wsContext = await context.AcceptWebSocketAsync(null);
_webSocket = wsContext.WebSocket;
await ReceiveWebsocketLoop(_cts.Token);
}

private static void HandleHttpRequest(HttpListenerContext context)

Check warning on line 156 in Editor/MCPServer.Networking.cs

View workflow job for this annotation

GitHub Actions / Static validation

NQG012 HandleHttpRequest

Method has 66 lines; consider splitting before it exceeds 150.

Check warning on line 156 in Editor/MCPServer.Networking.cs

View workflow job for this annotation

GitHub Actions / Documentation quality AI

NQG012 HandleHttpRequest

Method has 66 lines; consider splitting before it exceeds 150.
{
try {
if (!IsValidOrigin(context))
Expand All @@ -154,6 +163,12 @@
return;
}

if (!IsAuthorized(context))
{
RejectUnauthorized(context);
return;
}

if (context.Request.HttpMethod != "POST") {
context.Response.StatusCode = (int)HttpStatusCode.MethodNotAllowed;
context.Response.Close();
Expand Down Expand Up @@ -205,6 +220,22 @@
}
}

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];
Expand Down
22 changes: 22 additions & 0 deletions Editor/MCPServer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;

Check warning on line 1 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Static validation

NQG002

File has 308 lines; consider splitting before it exceeds 450.

Check warning on line 1 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Documentation quality AI

NQG002

File has 308 lines; consider splitting before it exceeds 450.
using System.Collections.Concurrent;
using System.IO;
using System.Net;
Expand Down Expand Up @@ -31,6 +31,9 @@

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; }
Expand Down Expand Up @@ -61,6 +64,7 @@
}
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;
Expand All @@ -84,10 +88,26 @@
_logs = new ConcurrentQueue<LogEntry>();
}

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();

[InitializeOnLoadMethod]

Check warning on line 110 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Static validation

NQG012 Init

Method has 59 lines; consider splitting before it exceeds 150.

Check warning on line 110 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Documentation quality AI

NQG012 Init

Method has 59 lines; consider splitting before it exceeds 150.
internal static void Init()
{
if (_mainThreadId == -1) _mainThreadId = Thread.CurrentThread.ManagedThreadId;
Expand All @@ -100,6 +120,7 @@
SessionState.SetString("MCP_SessionId", SessionId);
SessionGeneration = SessionState.GetInt("MCP_SessionGen", 0) + 1;
SessionState.SetInt("MCP_SessionGen", SessionGeneration);
EnsureAuthToken();
}

RefreshMainThreadCachedState();
Expand Down Expand Up @@ -171,7 +192,7 @@
/// The method initializes tool dispatch, resolves or allocates the configured port, records restart intent, may attach to an
/// existing Nexus Unity instance on the same port, and enables the macOS App Nap bypass before binding the listener.
/// </remarks>
public static void Start()

Check warning on line 195 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Static validation

NQG012 Start

Method has 72 lines; consider splitting before it exceeds 150.

Check warning on line 195 in Editor/MCPServer.cs

View workflow job for this annotation

GitHub Actions / Documentation quality AI

NQG012 Start

Method has 72 lines; consider splitting before it exceeds 150.
{
if (_mainThreadId != -1 && Thread.CurrentThread.ManagedThreadId != _mainThreadId)
{
Expand All @@ -188,6 +209,7 @@
}

MCPServerMethods.Init();
EnsureAuthToken();
if (EditorApplication.isPlaying) Application.runInBackground = true;

if (_port <= 0) {
Expand Down
31 changes: 26 additions & 5 deletions Editor/NexusMcpConfigGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -368,11 +379,21 @@ private static void RemoveTomlSection(List<string> 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<string> 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)
Expand Down
Loading
Loading