Add Codex to autoconfig options (#288)

* feat: add Codex CLI client support with config.toml handling

* feat: add config helpers for managing Codex and MCP server configurations

* feat: add TOML array parsing support for multi-line and trailing comma formats

* fix: handle TOML inline comments in section headers during parsing

* fix: strip TOML comments before processing section headers

* fix: improve JSON parsing to handle escaped single quotes in config strings

* Use Tommy for TOML parsing

It's a single file and OSS, easy to integrate into Unity

* fix: patched Tommy’s literal-string handling so doubled single quotes inside literal strings are treated as embedded apostrophes instead of prematurely ending the value

* Don't overwrite MCP configs while testing

Seeing random JSON in my codex config was pretty annoying

* PR Feedback

* Keep Tommy compatible with Unity 2021

* Re-include Tommy's license

Probably a good habit to keep all 3rd party licenses and copyrights, even if they're also MIT licenses
main
Marcus Sanatan 2025-09-26 18:05:30 -04:00 committed by GitHub
parent da91f256a2
commit 549ac1eb0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 2938 additions and 230 deletions

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d539787bf8f6a426e94bfffb32a36d4f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,103 @@
using NUnit.Framework;
using MCPForUnity.Editor.Helpers;
namespace MCPForUnityTests.Editor.Helpers
{
public class CodexConfigHelperTests
{
[Test]
public void TryParseCodexServer_SingleLineArgs_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uv\"",
"args = [\"run\", \"--directory\", \"/abs/path\", \"server.py\"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should detect server definition");
Assert.AreEqual("uv", command);
CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args);
}
[Test]
public void TryParseCodexServer_MultiLineArgsWithTrailingComma_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uv\"",
"args = [",
" \"run\",",
" \"--directory\",",
" \"/abs/path\",",
" \"server.py\",",
"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should handle multi-line arrays with trailing comma");
Assert.AreEqual("uv", command);
CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args);
}
[Test]
public void TryParseCodexServer_MultiLineArgsWithComments_IgnoresComments()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = \"uv\"",
"args = [",
" \"run\", # launch command",
" \"--directory\",",
" \"/abs/path\",",
" \"server.py\"",
"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should tolerate comments within the array block");
Assert.AreEqual("uv", command);
CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args);
}
[Test]
public void TryParseCodexServer_HeaderWithComment_StillDetected()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP] # annotated header",
"command = \"uv\"",
"args = [\"run\", \"--directory\", \"/abs/path\", \"server.py\"]"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should recognize section headers even with inline comments");
Assert.AreEqual("uv", command);
CollectionAssert.AreEqual(new[] { "run", "--directory", "/abs/path", "server.py" }, args);
}
[Test]
public void TryParseCodexServer_SingleQuotedArgsWithApostrophes_ParsesSuccessfully()
{
string toml = string.Join("\n", new[]
{
"[mcp_servers.unityMCP]",
"command = 'uv'",
"args = ['run', '--directory', '/Users/O''Connor/codex', 'server.py']"
});
bool result = CodexConfigHelper.TryParseCodexServer(toml, out string command, out string[] args);
Assert.IsTrue(result, "Parser should accept single-quoted arrays with escaped apostrophes");
Assert.AreEqual("uv", command);
CollectionAssert.AreEqual(new[] { "run", "--directory", "/Users/O'Connor/codex", "server.py" }, args);
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 013424dea29744a98b3dc01618f4e95e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -46,6 +46,8 @@ namespace MCPForUnityTests.Editor.Windows
EditorPrefs.SetString("MCPForUnity.ServerSrc", _serverSrcDir); EditorPrefs.SetString("MCPForUnity.ServerSrc", _serverSrcDir);
// Ensure no lock is enabled // Ensure no lock is enabled
EditorPrefs.SetBool("MCPForUnity.LockCursorConfig", false); EditorPrefs.SetBool("MCPForUnity.LockCursorConfig", false);
// Disable auto-registration to avoid hitting user configs during tests
EditorPrefs.SetBool("MCPForUnity.AutoRegisterEnabled", false);
} }
[TearDown] [TearDown]
@ -54,6 +56,7 @@ namespace MCPForUnityTests.Editor.Windows
// Clean up editor preferences set during SetUp // Clean up editor preferences set during SetUp
EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); EditorPrefs.DeleteKey("MCPForUnity.ServerSrc");
EditorPrefs.DeleteKey("MCPForUnity.LockCursorConfig"); EditorPrefs.DeleteKey("MCPForUnity.LockCursorConfig");
EditorPrefs.DeleteKey("MCPForUnity.AutoRegisterEnabled");
// Remove temp files // Remove temp files
try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { } try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { }

View File

@ -159,6 +159,28 @@ namespace MCPForUnity.Editor.Data
mcpType = McpTypes.Kiro, mcpType = McpTypes.Kiro,
configStatus = "Not Configured", configStatus = "Not Configured",
}, },
// 4) Codex CLI
new()
{
name = "Codex CLI",
windowsConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codex",
"config.toml"
),
macConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codex",
"config.toml"
),
linuxConfigPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".codex",
"config.toml"
),
mcpType = McpTypes.Codex,
configStatus = "Not Configured",
},
}; };
// Initialize status enums after construction // Initialize status enums after construction
@ -174,4 +196,3 @@ namespace MCPForUnity.Editor.Data
} }
} }
} }

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c11944bcfb9ec4576bab52874b7df584
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

2138
UnityMcpBridge/Editor/External/Tommy.cs vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ea652131dcdaa44ca8cb35cd1191be3f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using MCPForUnity.External.Tommy;
using Newtonsoft.Json;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Codex CLI specific configuration helpers. Handles TOML snippet
/// generation and lightweight parsing so Codex can join the auto-setup
/// flow alongside JSON-based clients.
/// </summary>
public static class CodexConfigHelper
{
public static bool IsCodexConfigured(string pythonDir)
{
try
{
string basePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(basePath)) return false;
string configPath = Path.Combine(basePath, ".codex", "config.toml");
if (!File.Exists(configPath)) return false;
string toml = File.ReadAllText(configPath);
if (!TryParseCodexServer(toml, out _, out var args)) return false;
string dir = McpConfigFileHelper.ExtractDirectoryArg(args);
if (string.IsNullOrEmpty(dir)) return false;
return McpConfigFileHelper.PathsEqual(dir, pythonDir);
}
catch
{
return false;
}
}
public static string BuildCodexServerBlock(string uvPath, string serverSrc)
{
string argsArray = FormatTomlStringArray(new[] { "run", "--directory", serverSrc, "server.py" });
return $"[mcp_servers.unityMCP]{Environment.NewLine}" +
$"command = \"{EscapeTomlString(uvPath)}\"{Environment.NewLine}" +
$"args = {argsArray}";
}
public static string UpsertCodexServerBlock(string existingToml, string newBlock)
{
if (string.IsNullOrWhiteSpace(existingToml))
{
return newBlock.TrimEnd() + Environment.NewLine;
}
StringBuilder sb = new StringBuilder();
using StringReader reader = new StringReader(existingToml);
string line;
bool inTarget = false;
bool replaced = false;
while ((line = reader.ReadLine()) != null)
{
string trimmed = line.Trim();
bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]") && !trimmed.StartsWith("[[");
if (isSection)
{
bool isTarget = string.Equals(trimmed, "[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase);
if (isTarget)
{
if (!replaced)
{
if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine();
sb.AppendLine(newBlock.TrimEnd());
replaced = true;
}
inTarget = true;
continue;
}
if (inTarget)
{
inTarget = false;
}
}
if (inTarget)
{
continue;
}
sb.AppendLine(line);
}
if (!replaced)
{
if (sb.Length > 0 && sb[^1] != '\n') sb.AppendLine();
sb.AppendLine(newBlock.TrimEnd());
}
return sb.ToString().TrimEnd() + Environment.NewLine;
}
public static bool TryParseCodexServer(string toml, out string command, out string[] args)
{
command = null;
args = null;
if (string.IsNullOrWhiteSpace(toml)) return false;
try
{
using var reader = new StringReader(toml);
TomlTable root = TOML.Parse(reader);
if (root == null) return false;
if (!TryGetTable(root, "mcp_servers", out var servers)
&& !TryGetTable(root, "mcpServers", out servers))
{
return false;
}
if (!TryGetTable(servers, "unityMCP", out var unity))
{
return false;
}
command = GetTomlString(unity, "command");
args = GetTomlStringArray(unity, "args");
return !string.IsNullOrEmpty(command) && args != null;
}
catch (TomlParseException)
{
return false;
}
catch (TomlSyntaxException)
{
return false;
}
catch (FormatException)
{
return false;
}
}
private static bool TryGetTable(TomlTable parent, string key, out TomlTable table)
{
table = null;
if (parent == null) return false;
if (parent.TryGetNode(key, out var node))
{
if (node is TomlTable tbl)
{
table = tbl;
return true;
}
if (node is TomlArray array)
{
var firstTable = array.Children.OfType<TomlTable>().FirstOrDefault();
if (firstTable != null)
{
table = firstTable;
return true;
}
}
}
return false;
}
private static string GetTomlString(TomlTable table, string key)
{
if (table != null && table.TryGetNode(key, out var node))
{
if (node is TomlString str) return str.Value;
if (node.HasValue) return node.ToString();
}
return null;
}
private static string[] GetTomlStringArray(TomlTable table, string key)
{
if (table == null) return null;
if (!table.TryGetNode(key, out var node)) return null;
if (node is TomlArray array)
{
List<string> values = new List<string>();
foreach (TomlNode element in array.Children)
{
if (element is TomlString str)
{
values.Add(str.Value);
}
else if (element.HasValue)
{
values.Add(element.ToString());
}
}
return values.Count > 0 ? values.ToArray() : Array.Empty<string>();
}
if (node is TomlString single)
{
return new[] { single.Value };
}
return null;
}
private static string FormatTomlStringArray(IEnumerable<string> values)
{
if (values == null) return "[]";
StringBuilder sb = new StringBuilder();
sb.Append('[');
bool first = true;
foreach (string value in values)
{
if (!first)
{
sb.Append(", ");
}
sb.Append('"').Append(EscapeTomlString(value ?? string.Empty)).Append('"');
first = false;
}
sb.Append(']');
return sb.ToString();
}
private static string EscapeTomlString(string value)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b3e68082ffc0b4cd39d3747673a4cc22
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,187 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using UnityEditor;
namespace MCPForUnity.Editor.Helpers
{
/// <summary>
/// Shared helpers for reading and writing MCP client configuration files.
/// Consolidates file atomics and server directory resolution so the editor
/// window can focus on UI concerns only.
/// </summary>
public static class McpConfigFileHelper
{
public static string ExtractDirectoryArg(string[] args)
{
if (args == null) return null;
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
public static bool PathsEqual(string a, string b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
try
{
string na = Path.GetFullPath(a.Trim());
string nb = Path.GetFullPath(b.Trim());
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
}
return string.Equals(na, nb, StringComparison.Ordinal);
}
catch
{
return false;
}
}
/// <summary>
/// Resolves the server directory to use for MCP tools, preferring
/// existing config values and falling back to installed/embedded copies.
/// </summary>
public static string ResolveServerDirectory(string pythonDir, string[] existingArgs)
{
string serverSrc = ExtractDirectoryArg(existingArgs);
bool serverValid = !string.IsNullOrEmpty(serverSrc)
&& File.Exists(Path.Combine(serverSrc, "server.py"));
if (!serverValid)
{
if (!string.IsNullOrEmpty(pythonDir)
&& File.Exists(Path.Combine(pythonDir, "server.py")))
{
serverSrc = pythonDir;
}
else
{
serverSrc = ResolveServerSource();
}
}
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && !string.IsNullOrEmpty(serverSrc))
{
string norm = serverSrc.Replace('\\', '/');
int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal);
if (idx >= 0)
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty;
string suffix = norm.Substring(idx + "/.local/share/".Length);
serverSrc = Path.Combine(home, "Library", "Application Support", suffix);
}
}
}
catch
{
// Ignore failures and fall back to the original path.
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
&& !string.IsNullOrEmpty(serverSrc)
&& serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0
&& !EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false))
{
serverSrc = ServerInstaller.GetServerPath();
}
return serverSrc;
}
public static void WriteAtomicFile(string path, string contents)
{
string tmp = path + ".tmp";
string backup = path + ".backup";
bool writeDone = false;
try
{
File.WriteAllText(tmp, contents, new UTF8Encoding(false));
try
{
File.Replace(tmp, path, backup);
writeDone = true;
}
catch (FileNotFoundException)
{
File.Move(tmp, path);
writeDone = true;
}
catch (PlatformNotSupportedException)
{
if (File.Exists(path))
{
try
{
if (File.Exists(backup)) File.Delete(backup);
}
catch { }
File.Move(path, backup);
}
File.Move(tmp, path);
writeDone = true;
}
}
catch (Exception ex)
{
try
{
if (!writeDone && File.Exists(backup))
{
try { File.Copy(backup, path, true); } catch { }
}
}
catch { }
throw new Exception($"Failed to write config file '{path}': {ex.Message}", ex);
}
finally
{
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
try { if (writeDone && File.Exists(backup)) File.Delete(backup); } catch { }
}
}
public static string ResolveServerSource()
{
try
{
string remembered = EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty);
if (!string.IsNullOrEmpty(remembered)
&& File.Exists(Path.Combine(remembered, "server.py")))
{
return remembered;
}
ServerInstaller.EnsureServerInstalled();
string installed = ServerInstaller.GetServerPath();
if (File.Exists(Path.Combine(installed, "server.py")))
{
return installed;
}
bool useEmbedded = EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false);
if (useEmbedded
&& ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)
&& File.Exists(Path.Combine(embedded, "server.py")))
{
return embedded;
}
return installed;
}
catch
{
return ServerInstaller.GetServerPath();
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f69ad468942b74c0ea24e3e8e5f21a4b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -4,10 +4,10 @@ namespace MCPForUnity.Editor.Models
{ {
ClaudeCode, ClaudeCode,
ClaudeDesktop, ClaudeDesktop,
Codex,
Cursor, Cursor,
Kiro,
VSCode, VSCode,
Windsurf, Windsurf,
Kiro,
} }
} }

View File

@ -568,8 +568,9 @@ namespace MCPForUnity.Editor.Windows
} }
else else
{ {
// For Cursor/others, skip if already configured CheckMcpConfiguration(client);
if (!IsCursorConfigured(pythonDir)) bool alreadyConfigured = client.status == McpStatus.Configured;
if (!alreadyConfigured)
{ {
ConfigureMcpClient(client); ConfigureMcpClient(client);
anyRegistered = true; anyRegistered = true;
@ -581,7 +582,10 @@ namespace MCPForUnity.Editor.Windows
MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}");
} }
} }
lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); lastClientRegisteredOk = anyRegistered
|| IsCursorConfigured(pythonDir)
|| CodexConfigHelper.IsCodexConfigured(pythonDir)
|| IsClaudeConfigured();
} }
// Ensure the bridge is listening and has a fresh saved port // Ensure the bridge is listening and has a fresh saved port
@ -658,7 +662,9 @@ namespace MCPForUnity.Editor.Windows
} }
else else
{ {
if (!IsCursorConfigured(pythonDir)) CheckMcpConfiguration(client);
bool alreadyConfigured = client.status == McpStatus.Configured;
if (!alreadyConfigured)
{ {
ConfigureMcpClient(client); ConfigureMcpClient(client);
anyRegistered = true; anyRegistered = true;
@ -670,7 +676,10 @@ namespace MCPForUnity.Editor.Windows
UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}");
} }
} }
lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); lastClientRegisteredOk = anyRegistered
|| IsCursorConfigured(pythonDir)
|| CodexConfigHelper.IsCodexConfigured(pythonDir)
|| IsClaudeConfigured();
// Restart/ensure bridge // Restart/ensure bridge
MCPForUnityBridge.StartAutoConnect(); MCPForUnityBridge.StartAutoConnect();
@ -686,11 +695,11 @@ namespace MCPForUnity.Editor.Windows
} }
} }
private static bool IsCursorConfigured(string pythonDir) private static bool IsCursorConfigured(string pythonDir)
{ {
try try
{ {
string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".cursor", "mcp.json") ".cursor", "mcp.json")
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
@ -708,24 +717,9 @@ namespace MCPForUnity.Editor.Windows
string[] strArgs = ((System.Collections.Generic.IEnumerable<object>)args) string[] strArgs = ((System.Collections.Generic.IEnumerable<object>)args)
.Select(x => x?.ToString() ?? string.Empty) .Select(x => x?.ToString() ?? string.Empty)
.ToArray(); .ToArray();
string dir = ExtractDirectoryArg(strArgs); string dir = McpConfigFileHelper.ExtractDirectoryArg(strArgs);
if (string.IsNullOrEmpty(dir)) return false; if (string.IsNullOrEmpty(dir)) return false;
return PathsEqual(dir, pythonDir); return McpConfigFileHelper.PathsEqual(dir, pythonDir);
}
catch { return false; }
}
private static bool PathsEqual(string a, string b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
try
{
string na = System.IO.Path.GetFullPath(a.Trim());
string nb = System.IO.Path.GetFullPath(b.Trim());
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
return string.Equals(na, nb, StringComparison.OrdinalIgnoreCase);
// Default to ordinal on Unix; optionally detect FS case-sensitivity at runtime if needed
return string.Equals(na, nb, StringComparison.Ordinal);
} }
catch { return false; } catch { return false; }
} }
@ -1136,19 +1130,6 @@ namespace MCPForUnity.Editor.Windows
catch { return false; } catch { return false; }
} }
private static string ExtractDirectoryArg(string[] args)
{
if (args == null) return null;
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], "--directory", StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
private static bool ArgsEqual(string[] a, string[] b) private static bool ArgsEqual(string[] a, string[] b)
{ {
if (a == null || b == null) return a == b; if (a == null || b == null) return a == b;
@ -1236,48 +1217,7 @@ namespace MCPForUnity.Editor.Windows
} }
catch { } catch { }
if (uvPath == null) return "UV package manager not found. Please install UV first."; if (uvPath == null) return "UV package manager not found. Please install UV first.";
string serverSrc = ExtractDirectoryArg(existingArgs); string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs);
bool serverValid = !string.IsNullOrEmpty(serverSrc)
&& System.IO.File.Exists(System.IO.Path.Combine(serverSrc, "server.py"));
if (!serverValid)
{
// Prefer the provided pythonDir if valid; fall back to resolver
if (!string.IsNullOrEmpty(pythonDir) && System.IO.File.Exists(System.IO.Path.Combine(pythonDir, "server.py")))
{
serverSrc = pythonDir;
}
else
{
serverSrc = ResolveServerSrc();
}
}
// macOS normalization: map XDG-style ~/.local/share to canonical Application Support
try
{
if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.OSX)
&& !string.IsNullOrEmpty(serverSrc))
{
string norm = serverSrc.Replace('\\', '/');
int idx = norm.IndexOf("/.local/share/UnityMCP/", StringComparison.Ordinal);
if (idx >= 0)
{
string home = Environment.GetFolderPath(Environment.SpecialFolder.Personal) ?? string.Empty;
string suffix = norm.Substring(idx + "/.local/share/".Length); // UnityMCP/...
serverSrc = System.IO.Path.Combine(home, "Library", "Application Support", suffix);
}
}
}
catch { }
// Hard-block PackageCache on Windows unless dev override is set
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
&& !string.IsNullOrEmpty(serverSrc)
&& serverSrc.IndexOf(@"\Library\PackageCache\", StringComparison.OrdinalIgnoreCase) >= 0
&& !UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false))
{
serverSrc = ServerInstaller.GetServerPath();
}
// 2) Canonical args order // 2) Canonical args order
var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; var newArgs = new[] { "run", "--directory", serverSrc, "server.py" };
@ -1301,60 +1241,7 @@ namespace MCPForUnity.Editor.Windows
string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings);
// Robust atomic write without redundant backup or race on existence McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson);
string tmp = configPath + ".tmp";
string backup = configPath + ".backup";
bool writeDone = false;
try
{
// Write to temp file first (in same directory for atomicity)
System.IO.File.WriteAllText(tmp, mergedJson, new System.Text.UTF8Encoding(false));
try
{
// Try atomic replace; creates 'backup' only on success (platform-dependent)
System.IO.File.Replace(tmp, configPath, backup);
writeDone = true;
}
catch (System.IO.FileNotFoundException)
{
// Destination didn't exist; fall back to move
System.IO.File.Move(tmp, configPath);
writeDone = true;
}
catch (System.PlatformNotSupportedException)
{
// Fallback: rename existing to backup, then move tmp into place
if (System.IO.File.Exists(configPath))
{
try { if (System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { }
System.IO.File.Move(configPath, backup);
}
System.IO.File.Move(tmp, configPath);
writeDone = true;
}
}
catch (Exception ex)
{
// If write did not complete, attempt restore from backup without deleting current file first
try
{
if (!writeDone && System.IO.File.Exists(backup))
{
try { System.IO.File.Copy(backup, configPath, true); } catch { }
}
}
catch { }
throw new Exception($"Failed to write config file '{configPath}': {ex.Message}", ex);
}
finally
{
// Best-effort cleanup of temp
try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { }
// Only remove backup after a confirmed successful write
try { if (writeDone && System.IO.File.Exists(backup)) System.IO.File.Delete(backup); } catch { }
}
try try
{ {
@ -1377,54 +1264,27 @@ namespace MCPForUnity.Editor.Windows
} }
// New method to show manual instructions without changing status // New method to show manual instructions without changing status
private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient) private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient)
{
// Get the Python directory path using Package Manager API
string pythonDir = FindPackagePythonDirectory();
// Build manual JSON centrally using the shared builder
string uvPathForManual = FindUvPath();
if (uvPathForManual == null)
{
UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration.");
return;
}
string manualConfigJson = ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient);
ManualConfigEditorWindow.ShowWindow(configPath, manualConfigJson, mcpClient);
}
private static string ResolveServerSrc()
{ {
try // Get the Python directory path using Package Manager API
string pythonDir = FindPackagePythonDirectory();
// Build manual JSON centrally using the shared builder
string uvPathForManual = FindUvPath();
if (uvPathForManual == null)
{ {
string remembered = UnityEditor.EditorPrefs.GetString("MCPForUnity.ServerSrc", string.Empty); UnityEngine.Debug.LogError("UV package manager not found. Cannot generate manual configuration.");
if (!string.IsNullOrEmpty(remembered) && File.Exists(Path.Combine(remembered, "server.py"))) return;
{
return remembered;
}
ServerInstaller.EnsureServerInstalled();
string installed = ServerInstaller.GetServerPath();
if (File.Exists(Path.Combine(installed, "server.py")))
{
return installed;
}
bool useEmbedded = UnityEditor.EditorPrefs.GetBool("MCPForUnity.UseEmbeddedServer", false);
if (useEmbedded && ServerPathResolver.TryFindEmbeddedServerSource(out string embedded)
&& File.Exists(Path.Combine(embedded, "server.py")))
{
return embedded;
}
return installed;
} }
catch { return ServerInstaller.GetServerPath(); }
string manualConfig = mcpClient?.mcpType == McpTypes.Codex
? CodexConfigHelper.BuildCodexServerBlock(uvPathForManual, McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine
: ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient);
ManualConfigEditorWindow.ShowWindow(configPath, manualConfig, mcpClient);
} }
private string FindPackagePythonDirectory() private string FindPackagePythonDirectory()
{ {
string pythonDir = ResolveServerSrc(); string pythonDir = McpConfigFileHelper.ResolveServerSource();
try try
{ {
@ -1508,12 +1368,12 @@ namespace MCPForUnity.Editor.Windows
} }
} }
private string ConfigureMcpClient(McpClient mcpClient) private string ConfigureMcpClient(McpClient mcpClient)
{ {
try try
{ {
// Determine the config file path based on OS // Determine the config file path based on OS
string configPath; string configPath;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{ {
@ -1541,21 +1401,23 @@ namespace MCPForUnity.Editor.Windows
// Create directory if it doesn't exist // Create directory if it doesn't exist
Directory.CreateDirectory(Path.GetDirectoryName(configPath)); Directory.CreateDirectory(Path.GetDirectoryName(configPath));
// Find the server.py file location using the same logic as FindPackagePythonDirectory // Find the server.py file location using the same logic as FindPackagePythonDirectory
string pythonDir = FindPackagePythonDirectory(); string pythonDir = FindPackagePythonDirectory();
if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py")))
{ {
ShowManualInstructionsWindow(configPath, mcpClient); ShowManualInstructionsWindow(configPath, mcpClient);
return "Manual Configuration Required"; return "Manual Configuration Required";
} }
string result = WriteToConfig(pythonDir, configPath, mcpClient); string result = mcpClient.mcpType == McpTypes.Codex
? ConfigureCodexClient(pythonDir, configPath, mcpClient)
: WriteToConfig(pythonDir, configPath, mcpClient);
// Update the client status after successful configuration // Update the client status after successful configuration
if (result == "Configured successfully") if (result == "Configured successfully")
{ {
mcpClient.SetStatus(McpStatus.Configured); mcpClient.SetStatus(McpStatus.Configured);
} }
return result; return result;
@ -1588,8 +1450,82 @@ namespace MCPForUnity.Editor.Windows
$"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}"
); );
return $"Failed to configure {mcpClient.name}"; return $"Failed to configure {mcpClient.name}";
} }
} }
private string ConfigureCodexClient(string pythonDir, string configPath, McpClient mcpClient)
{
try { if (EditorPrefs.GetBool("MCPForUnity.LockCursorConfig", false)) return "Skipped (locked)"; } catch { }
string existingToml = string.Empty;
if (File.Exists(configPath))
{
try
{
existingToml = File.ReadAllText(configPath);
}
catch (Exception e)
{
if (debugLogsEnabled)
{
UnityEngine.Debug.LogWarning($"UnityMCP: Failed to read Codex config '{configPath}': {e.Message}");
}
existingToml = string.Empty;
}
}
string existingCommand = null;
string[] existingArgs = null;
if (!string.IsNullOrWhiteSpace(existingToml))
{
CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs);
}
string uvPath = ServerInstaller.FindUvPath();
try
{
var name = Path.GetFileName((existingCommand ?? string.Empty).Trim()).ToLowerInvariant();
if ((name == "uv" || name == "uv.exe") && ValidateUvBinarySafe(existingCommand))
{
uvPath = existingCommand;
}
}
catch { }
if (uvPath == null)
{
return "UV package manager not found. Please install UV first.";
}
string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs);
var newArgs = new[] { "run", "--directory", serverSrc, "server.py" };
bool changed = true;
if (!string.IsNullOrEmpty(existingCommand) && existingArgs != null)
{
changed = !string.Equals(existingCommand, uvPath, StringComparison.Ordinal)
|| !ArgsEqual(existingArgs, newArgs);
}
if (!changed)
{
return "Configured successfully";
}
string codexBlock = CodexConfigHelper.BuildCodexServerBlock(uvPath, serverSrc);
string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, codexBlock);
McpConfigFileHelper.WriteAtomicFile(configPath, updatedToml);
try
{
if (IsValidUv(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath);
EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc);
}
catch { }
return "Configured successfully";
}
private void ShowCursorManualConfigurationInstructions( private void ShowCursorManualConfigurationInstructions(
string configPath, string configPath,
@ -1721,28 +1657,36 @@ namespace MCPForUnity.Editor.Windows
string[] args = null; string[] args = null;
bool configExists = false; bool configExists = false;
switch (mcpClient.mcpType) switch (mcpClient.mcpType)
{ {
case McpTypes.VSCode: case McpTypes.VSCode:
dynamic config = JsonConvert.DeserializeObject(configJson); dynamic config = JsonConvert.DeserializeObject(configJson);
// New schema: top-level servers // New schema: top-level servers
if (config?.servers?.unityMCP != null) if (config?.servers?.unityMCP != null)
{ {
args = config.servers.unityMCP.args.ToObject<string[]>(); args = config.servers.unityMCP.args.ToObject<string[]>();
configExists = true; configExists = true;
} }
// Back-compat: legacy mcp.servers // Back-compat: legacy mcp.servers
else if (config?.mcp?.servers?.unityMCP != null) else if (config?.mcp?.servers?.unityMCP != null)
{ {
args = config.mcp.servers.unityMCP.args.ToObject<string[]>(); args = config.mcp.servers.unityMCP.args.ToObject<string[]>();
configExists = true; configExists = true;
} }
break; break;
default: case McpTypes.Codex:
// Standard MCP configuration check for Claude Desktop, Cursor, etc. if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs))
McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson); {
args = codexArgs;
configExists = true;
}
break;
default:
// Standard MCP configuration check for Claude Desktop, Cursor, etc.
McpConfig standardConfig = JsonConvert.DeserializeObject<McpConfig>(configJson);
if (standardConfig?.mcpServers?.unityMCP != null) if (standardConfig?.mcpServers?.unityMCP != null)
{ {
@ -1755,8 +1699,8 @@ namespace MCPForUnity.Editor.Windows
// Common logic for checking configuration status // Common logic for checking configuration status
if (configExists) if (configExists)
{ {
string configuredDir = ExtractDirectoryArg(args); string configuredDir = McpConfigFileHelper.ExtractDirectoryArg(args);
bool matches = !string.IsNullOrEmpty(configuredDir) && PathsEqual(configuredDir, pythonDir); bool matches = !string.IsNullOrEmpty(configuredDir) && McpConfigFileHelper.PathsEqual(configuredDir, pythonDir);
if (matches) if (matches)
{ {
mcpClient.SetStatus(McpStatus.Configured); mcpClient.SetStatus(McpStatus.Configured);
@ -1766,7 +1710,9 @@ namespace MCPForUnity.Editor.Windows
// Attempt auto-rewrite once if the package path changed // Attempt auto-rewrite once if the package path changed
try try
{ {
string rewriteResult = WriteToConfig(pythonDir, configPath, mcpClient); string rewriteResult = mcpClient.mcpType == McpTypes.Codex
? ConfigureCodexClient(pythonDir, configPath, mcpClient)
: WriteToConfig(pythonDir, configPath, mcpClient);
if (rewriteResult == "Configured successfully") if (rewriteResult == "Configured successfully")
{ {
if (debugLogsEnabled) if (debugLogsEnabled)

View File

@ -101,6 +101,13 @@ namespace MCPForUnity.Editor.Windows
instructionStyle instructionStyle
); );
} }
else if (mcpClient?.mcpType == McpTypes.Codex)
{
EditorGUILayout.LabelField(
" a) Running `codex config edit` in a terminal",
instructionStyle
);
}
EditorGUILayout.LabelField(" OR", instructionStyle); EditorGUILayout.LabelField(" OR", instructionStyle);
EditorGUILayout.LabelField( EditorGUILayout.LabelField(
" b) Opening the configuration file at:", " b) Opening the configuration file at:",
@ -201,10 +208,10 @@ namespace MCPForUnity.Editor.Windows
EditorGUILayout.Space(10); EditorGUILayout.Space(10);
EditorGUILayout.LabelField( string configLabel = mcpClient?.mcpType == McpTypes.Codex
"2. Paste the following JSON configuration:", ? "2. Paste the following TOML configuration:"
instructionStyle : "2. Paste the following JSON configuration:";
); EditorGUILayout.LabelField(configLabel, instructionStyle);
// JSON section with improved styling // JSON section with improved styling
EditorGUILayout.BeginVertical(EditorStyles.helpBox); EditorGUILayout.BeginVertical(EditorStyles.helpBox);