From f14a22c1295fe7da069a672183b5d42d4e3aea69 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 25 Sep 2025 23:15:19 -0400 Subject: [PATCH 01/12] feat: add Codex CLI client support with config.toml handling --- UnityMcpBridge/Editor/Data/McpClients.cs | 23 +- UnityMcpBridge/Editor/Models/McpTypes.cs | 4 +- .../Editor/Windows/MCPForUnityEditorWindow.cs | 650 +++++++++++++----- .../Windows/ManualConfigEditorWindow.cs | 15 +- 4 files changed, 526 insertions(+), 166 deletions(-) diff --git a/UnityMcpBridge/Editor/Data/McpClients.cs b/UnityMcpBridge/Editor/Data/McpClients.cs index 19e41284f..9e7188470 100644 --- a/UnityMcpBridge/Editor/Data/McpClients.cs +++ b/UnityMcpBridge/Editor/Data/McpClients.cs @@ -159,6 +159,28 @@ public class McpClients mcpType = McpTypes.Kiro, 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 @@ -174,4 +196,3 @@ public McpClients() } } } - diff --git a/UnityMcpBridge/Editor/Models/McpTypes.cs b/UnityMcpBridge/Editor/Models/McpTypes.cs index 32a30e2f8..a5a03decf 100644 --- a/UnityMcpBridge/Editor/Models/McpTypes.cs +++ b/UnityMcpBridge/Editor/Models/McpTypes.cs @@ -4,10 +4,10 @@ public enum McpTypes { ClaudeCode, ClaudeDesktop, + Codex, Cursor, + Kiro, VSCode, Windsurf, - Kiro, } } - diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 84113f7d2..1c651f760 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -581,7 +581,7 @@ private void AutoFirstRunSetup() MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); } } - lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); + lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsCodexConfigured(pythonDir) || IsClaudeConfigured(); } // Ensure the bridge is listening and has a fresh saved port @@ -670,7 +670,7 @@ private void RunSetupNow() UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); } } - lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsClaudeConfigured(); + lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsCodexConfigured(pythonDir) || IsClaudeConfigured(); // Restart/ensure bridge MCPForUnityBridge.StartAutoConnect(); @@ -686,11 +686,11 @@ private void RunSetupNow() } } - private static bool IsCursorConfigured(string pythonDir) - { - try - { - string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + private static bool IsCursorConfigured(string pythonDir) + { + try + { + string configPath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cursor", "mcp.json") : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), @@ -1136,6 +1136,23 @@ private static bool ValidateUvBinarySafe(string path) catch { return false; } } + private 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 = ExtractDirectoryArg(args); + if (string.IsNullOrEmpty(dir)) return false; + return PathsEqual(dir, pythonDir); + } + catch { return false; } + } + private static string ExtractDirectoryArg(string[] args) { if (args == null) return null; @@ -1160,6 +1177,329 @@ private static bool ArgsEqual(string[] a, string[] b) return true; } + private 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 = ResolveServerSrc(); + } + } + + 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 { } + + 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; + } + + private 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 { } + } + } + + private 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}"; + } + + private static string FormatTomlStringArray(IEnumerable 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("\"", "\\\""); + } + + private 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[sb.Length - 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[sb.Length - 1] != '\n') sb.AppendLine(); + sb.AppendLine(newBlock.TrimEnd()); + } + + return sb.ToString().TrimEnd() + Environment.NewLine; + } + + private static bool TryParseCodexServer(string toml, out string command, out string[] args) + { + command = null; + args = null; + if (string.IsNullOrEmpty(toml)) return false; + + using StringReader reader = new StringReader(toml); + string line; + bool inTarget = false; + while ((line = reader.ReadLine()) != null) + { + string trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("#")) continue; + bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]") && !trimmed.StartsWith("[["); + if (isSection) + { + inTarget = string.Equals(trimmed, "[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase); + continue; + } + + if (!inTarget) continue; + + if (trimmed.StartsWith("command", StringComparison.OrdinalIgnoreCase)) + { + int eq = trimmed.IndexOf('='); + if (eq >= 0) + { + string raw = trimmed.Substring(eq + 1); + command = ParseTomlStringValue(raw); + } + } + else if (trimmed.StartsWith("args", StringComparison.OrdinalIgnoreCase)) + { + int eq = trimmed.IndexOf('='); + if (eq >= 0) + { + string raw = trimmed.Substring(eq + 1); + args = ParseTomlStringArray(raw); + } + } + } + + return !string.IsNullOrEmpty(command) && args != null; + } + + private static string ParseTomlStringValue(string value) + { + if (value == null) return null; + string trimmed = StripTomlComment(value).Trim(); + if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"') + { + return UnescapeTomlBasicString(trimmed.Substring(1, trimmed.Length - 2)); + } + if (trimmed.Length >= 2 && trimmed[0] == '\'' && trimmed[^1] == '\'') + { + return trimmed.Substring(1, trimmed.Length - 2); + } + return trimmed.Trim(); + } + + private static string[] ParseTomlStringArray(string value) + { + if (value == null) return null; + string cleaned = StripTomlComment(value).Trim(); + if (!cleaned.StartsWith("[") || !cleaned.EndsWith("]")) return null; + try + { + return JsonConvert.DeserializeObject(cleaned); + } + catch + { + if (cleaned.IndexOf('\"') < 0 && cleaned.IndexOf('\'') >= 0) + { + string alt = cleaned.Replace('\'', '\"'); + try { return JsonConvert.DeserializeObject(alt); } catch { } + } + } + return null; + } + + private static string StripTomlComment(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + bool inDouble = false; + bool inSingle = false; + bool escape = false; + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (escape) + { + escape = false; + continue; + } + if (c == '\\' && inDouble) + { + escape = true; + continue; + } + if (c == '"' && !inSingle) + { + inDouble = !inDouble; + continue; + } + if (c == '\'' && !inDouble) + { + inSingle = !inSingle; + continue; + } + if (c == '#' && !inSingle && !inDouble) + { + return value.Substring(0, i).TrimEnd(); + } + } + return value.Trim(); + } + + private static string UnescapeTomlBasicString(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + StringBuilder sb = new StringBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (c == '\\' && i + 1 < value.Length) + { + char next = value[++i]; + sb.Append(next switch + { + '\\' => '\\', + '"' => '"', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'b' => '\b', + 'f' => '\f', + _ => next + }); + continue; + } + sb.Append(c); + } + return sb.ToString(); + } + private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) { // 0) Respect explicit lock (hidden pref or UI toggle) @@ -1236,48 +1576,7 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC } catch { } if (uvPath == null) return "UV package manager not found. Please install UV first."; - string serverSrc = ExtractDirectoryArg(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(); - } + string serverSrc = ResolveServerDirectory(pythonDir, existingArgs); // 2) Canonical args order var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; @@ -1301,60 +1600,7 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); - // Robust atomic write without redundant backup or race on existence - 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 { } - } + WriteAtomicFile(configPath, mergedJson); try { @@ -1377,21 +1623,23 @@ McpClient mcpClient } // New method to show manual instructions without changing status - 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; - } + 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); - } + string manualConfig = mcpClient?.mcpType == McpTypes.Codex + ? BuildCodexServerBlock(uvPathForManual, ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine + : ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient); + ManualConfigEditorWindow.ShowWindow(configPath, manualConfig, mcpClient); + } private static string ResolveServerSrc() { @@ -1508,12 +1756,12 @@ private bool IsDevelopmentMode() } } - private string ConfigureMcpClient(McpClient mcpClient) - { - try - { - // Determine the config file path based on OS - string configPath; + private string ConfigureMcpClient(McpClient mcpClient) + { + try + { + // Determine the config file path based on OS + string configPath; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -1541,21 +1789,23 @@ private string ConfigureMcpClient(McpClient mcpClient) // Create directory if it doesn't exist Directory.CreateDirectory(Path.GetDirectoryName(configPath)); - // Find the server.py file location using the same logic as FindPackagePythonDirectory - string pythonDir = FindPackagePythonDirectory(); + // Find the server.py file location using the same logic as FindPackagePythonDirectory + string pythonDir = FindPackagePythonDirectory(); - if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) - { - ShowManualInstructionsWindow(configPath, mcpClient); - return "Manual Configuration Required"; - } + if (pythonDir == null || !File.Exists(Path.Combine(pythonDir, "server.py"))) + { + ShowManualInstructionsWindow(configPath, mcpClient); + 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 - if (result == "Configured successfully") - { - mcpClient.SetStatus(McpStatus.Configured); + // Update the client status after successful configuration + if (result == "Configured successfully") + { + mcpClient.SetStatus(McpStatus.Configured); } return result; @@ -1588,8 +1838,82 @@ private string ConfigureMcpClient(McpClient mcpClient) $"Failed to configure {mcpClient.name}: {e.Message}\n{e.StackTrace}" ); 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)) + { + 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 = 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 = BuildCodexServerBlock(uvPath, serverSrc); + string updatedToml = UpsertCodexServerBlock(existingToml, codexBlock); + + WriteAtomicFile(configPath, updatedToml); + + try + { + if (IsValidUv(uvPath)) EditorPrefs.SetString("MCPForUnity.UvPath", uvPath); + EditorPrefs.SetString("MCPForUnity.ServerSrc", serverSrc); + } + catch { } + + return "Configured successfully"; + } private void ShowCursorManualConfigurationInstructions( string configPath, @@ -1721,28 +2045,36 @@ private void CheckMcpConfiguration(McpClient mcpClient) string[] args = null; bool configExists = false; - switch (mcpClient.mcpType) - { - case McpTypes.VSCode: - dynamic config = JsonConvert.DeserializeObject(configJson); - - // New schema: top-level servers - if (config?.servers?.unityMCP != null) - { - args = config.servers.unityMCP.args.ToObject(); - configExists = true; - } - // Back-compat: legacy mcp.servers - else if (config?.mcp?.servers?.unityMCP != null) - { - args = config.mcp.servers.unityMCP.args.ToObject(); - configExists = true; - } - break; - - default: - // Standard MCP configuration check for Claude Desktop, Cursor, etc. - McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); + switch (mcpClient.mcpType) + { + case McpTypes.VSCode: + dynamic config = JsonConvert.DeserializeObject(configJson); + + // New schema: top-level servers + if (config?.servers?.unityMCP != null) + { + args = config.servers.unityMCP.args.ToObject(); + configExists = true; + } + // Back-compat: legacy mcp.servers + else if (config?.mcp?.servers?.unityMCP != null) + { + args = config.mcp.servers.unityMCP.args.ToObject(); + configExists = true; + } + break; + + case McpTypes.Codex: + if (TryParseCodexServer(configJson, out _, out var codexArgs)) + { + args = codexArgs; + configExists = true; + } + break; + + default: + // Standard MCP configuration check for Claude Desktop, Cursor, etc. + McpConfig standardConfig = JsonConvert.DeserializeObject(configJson); if (standardConfig?.mcpServers?.unityMCP != null) { diff --git a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs index 501e37a47..ecccbef16 100644 --- a/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/ManualConfigEditorWindow.cs @@ -101,6 +101,13 @@ protected virtual void OnGUI() instructionStyle ); } + else if (mcpClient?.mcpType == McpTypes.Codex) + { + EditorGUILayout.LabelField( + " a) Running `codex config edit` in a terminal", + instructionStyle + ); + } EditorGUILayout.LabelField(" OR", instructionStyle); EditorGUILayout.LabelField( " b) Opening the configuration file at:", @@ -201,10 +208,10 @@ protected virtual void OnGUI() EditorGUILayout.Space(10); - EditorGUILayout.LabelField( - "2. Paste the following JSON configuration:", - instructionStyle - ); + string configLabel = mcpClient?.mcpType == McpTypes.Codex + ? "2. Paste the following TOML configuration:" + : "2. Paste the following JSON configuration:"; + EditorGUILayout.LabelField(configLabel, instructionStyle); // JSON section with improved styling EditorGUILayout.BeginVertical(EditorStyles.helpBox); From cdbf5bebb063668de8055a6dc8f895fb40c23441 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Thu, 25 Sep 2025 23:39:11 -0400 Subject: [PATCH 02/12] feat: add config helpers for managing Codex and MCP server configurations --- .../Editor/Helpers/CodexConfigHelper.cs | 275 ++++++++++++ .../Editor/Helpers/CodexConfigHelper.cs.meta | 11 + .../Editor/Helpers/McpConfigFileHelper.cs | 187 ++++++++ .../Helpers/McpConfigFileHelper.cs.meta | 11 + .../Editor/Windows/MCPForUnityEditorWindow.cs | 406 ++---------------- 5 files changed, 521 insertions(+), 369 deletions(-) create mode 100644 UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs create mode 100644 UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs.meta create mode 100644 UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs create mode 100644 UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs.meta diff --git a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs new file mode 100644 index 000000000..15164c95c --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Newtonsoft.Json; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// Codex CLI specific configuration helpers. Handles TOML snippet + /// generation and lightweight parsing so Codex can join the auto-setup + /// flow alongside JSON-based clients. + /// + 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.IsNullOrEmpty(toml)) return false; + + using StringReader reader = new StringReader(toml); + string line; + bool inTarget = false; + while ((line = reader.ReadLine()) != null) + { + string trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("#")) continue; + + bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]") && !trimmed.StartsWith("[["); + if (isSection) + { + inTarget = string.Equals(trimmed, "[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase); + continue; + } + + if (!inTarget) continue; + + if (trimmed.StartsWith("command", StringComparison.OrdinalIgnoreCase)) + { + int eq = trimmed.IndexOf('='); + if (eq >= 0) + { + string raw = trimmed[(eq + 1)..]; + command = ParseTomlStringValue(raw); + } + } + else if (trimmed.StartsWith("args", StringComparison.OrdinalIgnoreCase)) + { + int eq = trimmed.IndexOf('='); + if (eq >= 0) + { + string raw = trimmed[(eq + 1)..]; + args = ParseTomlStringArray(raw); + } + } + } + + return !string.IsNullOrEmpty(command) && args != null; + } + + private static string FormatTomlStringArray(IEnumerable 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("\"", "\\\""); + } + + private static string ParseTomlStringValue(string value) + { + if (value == null) return null; + string trimmed = StripTomlComment(value).Trim(); + if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"') + { + return UnescapeTomlBasicString(trimmed.Substring(1, trimmed.Length - 2)); + } + if (trimmed.Length >= 2 && trimmed[0] == '\'' && trimmed[^1] == '\'') + { + return trimmed.Substring(1, trimmed.Length - 2); + } + return trimmed.Trim(); + } + + private static string[] ParseTomlStringArray(string value) + { + if (value == null) return null; + string cleaned = StripTomlComment(value).Trim(); + if (!cleaned.StartsWith("[") || !cleaned.EndsWith("]")) return null; + try + { + return JsonConvert.DeserializeObject(cleaned); + } + catch + { + if (cleaned.IndexOf('"') < 0 && cleaned.IndexOf('\'') >= 0) + { + string alt = cleaned.Replace('\'', '\"'); + try { return JsonConvert.DeserializeObject(alt); } catch { } + } + } + return null; + } + + private static string StripTomlComment(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + bool inDouble = false; + bool inSingle = false; + bool escape = false; + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (escape) + { + escape = false; + continue; + } + if (c == '\\' && inDouble) + { + escape = true; + continue; + } + if (c == '"' && !inSingle) + { + inDouble = !inDouble; + continue; + } + if (c == '\'' && !inDouble) + { + inSingle = !inSingle; + continue; + } + if (c == '#' && !inSingle && !inDouble) + { + return value.Substring(0, i).TrimEnd(); + } + } + return value.Trim(); + } + + private static string UnescapeTomlBasicString(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + StringBuilder sb = new StringBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (c == '\\' && i + 1 < value.Length) + { + char next = value[++i]; + sb.Append(next switch + { + '\\' => '\\', + '"' => '"', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'b' => '\b', + 'f' => '\f', + _ => next + }); + continue; + } + sb.Append(c); + } + return sb.ToString(); + } + } +} diff --git a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs.meta b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs.meta new file mode 100644 index 000000000..581a44743 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3e68082ffc0b4cd39d3747673a4cc22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs b/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs new file mode 100644 index 000000000..9b2e5b869 --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs @@ -0,0 +1,187 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using UnityEditor; + +namespace MCPForUnity.Editor.Helpers +{ + /// + /// 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. + /// + 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; + } + } + + /// + /// Resolves the server directory to use for MCP tools, preferring + /// existing config values and falling back to installed/embedded copies. + /// + 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(); + } + } + } +} + diff --git a/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs.meta b/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs.meta new file mode 100644 index 000000000..8f81ae99e --- /dev/null +++ b/UnityMcpBridge/Editor/Helpers/McpConfigFileHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f69ad468942b74c0ea24e3e8e5f21a4b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 1c651f760..8765856ac 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -568,8 +568,13 @@ private void AutoFirstRunSetup() } else { - // For Cursor/others, skip if already configured - if (!IsCursorConfigured(pythonDir)) + bool alreadyConfigured = client.mcpType switch + { + McpTypes.Codex => CodexConfigHelper.IsCodexConfigured(pythonDir), + _ => IsCursorConfigured(pythonDir) + }; + + if (!alreadyConfigured) { ConfigureMcpClient(client); anyRegistered = true; @@ -581,7 +586,10 @@ private void AutoFirstRunSetup() MCPForUnity.Editor.Helpers.McpLog.Warn($"Auto-setup client '{client.name}' failed: {ex.Message}"); } } - lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsCodexConfigured(pythonDir) || IsClaudeConfigured(); + lastClientRegisteredOk = anyRegistered + || IsCursorConfigured(pythonDir) + || CodexConfigHelper.IsCodexConfigured(pythonDir) + || IsClaudeConfigured(); } // Ensure the bridge is listening and has a fresh saved port @@ -658,7 +666,13 @@ private void RunSetupNow() } else { - if (!IsCursorConfigured(pythonDir)) + bool alreadyConfigured = client.mcpType switch + { + McpTypes.Codex => CodexConfigHelper.IsCodexConfigured(pythonDir), + _ => IsCursorConfigured(pythonDir) + }; + + if (!alreadyConfigured) { ConfigureMcpClient(client); anyRegistered = true; @@ -670,7 +684,10 @@ private void RunSetupNow() UnityEngine.Debug.LogWarning($"Setup client '{client.name}' failed: {ex.Message}"); } } - lastClientRegisteredOk = anyRegistered || IsCursorConfigured(pythonDir) || IsCodexConfigured(pythonDir) || IsClaudeConfigured(); + lastClientRegisteredOk = anyRegistered + || IsCursorConfigured(pythonDir) + || CodexConfigHelper.IsCodexConfigured(pythonDir) + || IsClaudeConfigured(); // Restart/ensure bridge MCPForUnityBridge.StartAutoConnect(); @@ -708,24 +725,9 @@ private static bool IsCursorConfigured(string pythonDir) string[] strArgs = ((System.Collections.Generic.IEnumerable)args) .Select(x => x?.ToString() ?? string.Empty) .ToArray(); - string dir = ExtractDirectoryArg(strArgs); + string dir = McpConfigFileHelper.ExtractDirectoryArg(strArgs); if (string.IsNullOrEmpty(dir)) return false; - return 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); + return McpConfigFileHelper.PathsEqual(dir, pythonDir); } catch { return false; } } @@ -1136,36 +1138,6 @@ private static bool ValidateUvBinarySafe(string path) catch { return false; } } - private 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 = ExtractDirectoryArg(args); - if (string.IsNullOrEmpty(dir)) return false; - return PathsEqual(dir, pythonDir); - } - 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) { if (a == null || b == null) return a == b; @@ -1177,50 +1149,6 @@ private static bool ArgsEqual(string[] a, string[] b) return true; } - private 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 = ResolveServerSrc(); - } - } - - 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 { } - - 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; - } - private static void WriteAtomicFile(string path, string contents) { string tmp = path + ".tmp"; @@ -1269,237 +1197,6 @@ private static void WriteAtomicFile(string path, string contents) } } - private 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}"; - } - - private static string FormatTomlStringArray(IEnumerable 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("\"", "\\\""); - } - - private 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[sb.Length - 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[sb.Length - 1] != '\n') sb.AppendLine(); - sb.AppendLine(newBlock.TrimEnd()); - } - - return sb.ToString().TrimEnd() + Environment.NewLine; - } - - private static bool TryParseCodexServer(string toml, out string command, out string[] args) - { - command = null; - args = null; - if (string.IsNullOrEmpty(toml)) return false; - - using StringReader reader = new StringReader(toml); - string line; - bool inTarget = false; - while ((line = reader.ReadLine()) != null) - { - string trimmed = line.Trim(); - if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("#")) continue; - bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]") && !trimmed.StartsWith("[["); - if (isSection) - { - inTarget = string.Equals(trimmed, "[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase); - continue; - } - - if (!inTarget) continue; - - if (trimmed.StartsWith("command", StringComparison.OrdinalIgnoreCase)) - { - int eq = trimmed.IndexOf('='); - if (eq >= 0) - { - string raw = trimmed.Substring(eq + 1); - command = ParseTomlStringValue(raw); - } - } - else if (trimmed.StartsWith("args", StringComparison.OrdinalIgnoreCase)) - { - int eq = trimmed.IndexOf('='); - if (eq >= 0) - { - string raw = trimmed.Substring(eq + 1); - args = ParseTomlStringArray(raw); - } - } - } - - return !string.IsNullOrEmpty(command) && args != null; - } - - private static string ParseTomlStringValue(string value) - { - if (value == null) return null; - string trimmed = StripTomlComment(value).Trim(); - if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"') - { - return UnescapeTomlBasicString(trimmed.Substring(1, trimmed.Length - 2)); - } - if (trimmed.Length >= 2 && trimmed[0] == '\'' && trimmed[^1] == '\'') - { - return trimmed.Substring(1, trimmed.Length - 2); - } - return trimmed.Trim(); - } - - private static string[] ParseTomlStringArray(string value) - { - if (value == null) return null; - string cleaned = StripTomlComment(value).Trim(); - if (!cleaned.StartsWith("[") || !cleaned.EndsWith("]")) return null; - try - { - return JsonConvert.DeserializeObject(cleaned); - } - catch - { - if (cleaned.IndexOf('\"') < 0 && cleaned.IndexOf('\'') >= 0) - { - string alt = cleaned.Replace('\'', '\"'); - try { return JsonConvert.DeserializeObject(alt); } catch { } - } - } - return null; - } - - private static string StripTomlComment(string value) - { - if (string.IsNullOrEmpty(value)) return string.Empty; - bool inDouble = false; - bool inSingle = false; - bool escape = false; - for (int i = 0; i < value.Length; i++) - { - char c = value[i]; - if (escape) - { - escape = false; - continue; - } - if (c == '\\' && inDouble) - { - escape = true; - continue; - } - if (c == '"' && !inSingle) - { - inDouble = !inDouble; - continue; - } - if (c == '\'' && !inDouble) - { - inSingle = !inSingle; - continue; - } - if (c == '#' && !inSingle && !inDouble) - { - return value.Substring(0, i).TrimEnd(); - } - } - return value.Trim(); - } - - private static string UnescapeTomlBasicString(string value) - { - if (string.IsNullOrEmpty(value)) return string.Empty; - StringBuilder sb = new StringBuilder(value.Length); - for (int i = 0; i < value.Length; i++) - { - char c = value[i]; - if (c == '\\' && i + 1 < value.Length) - { - char next = value[++i]; - sb.Append(next switch - { - '\\' => '\\', - '"' => '"', - 'n' => '\n', - 'r' => '\r', - 't' => '\t', - 'b' => '\b', - 'f' => '\f', - _ => next - }); - continue; - } - sb.Append(c); - } - return sb.ToString(); - } - private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) { // 0) Respect explicit lock (hidden pref or UI toggle) @@ -1576,7 +1273,7 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC } catch { } if (uvPath == null) return "UV package manager not found. Please install UV first."; - string serverSrc = ResolveServerDirectory(pythonDir, existingArgs); + string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs); // 2) Canonical args order var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; @@ -1600,7 +1297,7 @@ private string WriteToConfig(string pythonDir, string configPath, McpClient mcpC string mergedJson = JsonConvert.SerializeObject(existingRoot, jsonSettings); - WriteAtomicFile(configPath, mergedJson); + McpConfigFileHelper.WriteAtomicFile(configPath, mergedJson); try { @@ -1636,43 +1333,14 @@ private void ShowManualInstructionsWindow(string configPath, McpClient mcpClient } string manualConfig = mcpClient?.mcpType == McpTypes.Codex - ? BuildCodexServerBlock(uvPathForManual, ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine + ? CodexConfigHelper.BuildCodexServerBlock(uvPathForManual, McpConfigFileHelper.ResolveServerDirectory(pythonDir, null)).TrimEnd() + Environment.NewLine : ConfigJsonBuilder.BuildManualConfigJson(uvPathForManual, pythonDir, mcpClient); ManualConfigEditorWindow.ShowWindow(configPath, manualConfig, mcpClient); } - private static string ResolveServerSrc() - { - try - { - string remembered = UnityEditor.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 = 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(); } - } - - private string FindPackagePythonDirectory() + private string FindPackagePythonDirectory() { - string pythonDir = ResolveServerSrc(); + string pythonDir = McpConfigFileHelper.ResolveServerSource(); try { @@ -1866,7 +1534,7 @@ private string ConfigureCodexClient(string pythonDir, string configPath, McpClie string[] existingArgs = null; if (!string.IsNullOrWhiteSpace(existingToml)) { - TryParseCodexServer(existingToml, out existingCommand, out existingArgs); + CodexConfigHelper.TryParseCodexServer(existingToml, out existingCommand, out existingArgs); } string uvPath = ServerInstaller.FindUvPath(); @@ -1885,7 +1553,7 @@ private string ConfigureCodexClient(string pythonDir, string configPath, McpClie return "UV package manager not found. Please install UV first."; } - string serverSrc = ResolveServerDirectory(pythonDir, existingArgs); + string serverSrc = McpConfigFileHelper.ResolveServerDirectory(pythonDir, existingArgs); var newArgs = new[] { "run", "--directory", serverSrc, "server.py" }; bool changed = true; @@ -1900,10 +1568,10 @@ private string ConfigureCodexClient(string pythonDir, string configPath, McpClie return "Configured successfully"; } - string codexBlock = BuildCodexServerBlock(uvPath, serverSrc); - string updatedToml = UpsertCodexServerBlock(existingToml, codexBlock); + string codexBlock = CodexConfigHelper.BuildCodexServerBlock(uvPath, serverSrc); + string updatedToml = CodexConfigHelper.UpsertCodexServerBlock(existingToml, codexBlock); - WriteAtomicFile(configPath, updatedToml); + McpConfigFileHelper.WriteAtomicFile(configPath, updatedToml); try { @@ -2065,7 +1733,7 @@ private void CheckMcpConfiguration(McpClient mcpClient) break; case McpTypes.Codex: - if (TryParseCodexServer(configJson, out _, out var codexArgs)) + if (CodexConfigHelper.TryParseCodexServer(configJson, out _, out var codexArgs)) { args = codexArgs; configExists = true; @@ -2087,8 +1755,8 @@ private void CheckMcpConfiguration(McpClient mcpClient) // Common logic for checking configuration status if (configExists) { - string configuredDir = ExtractDirectoryArg(args); - bool matches = !string.IsNullOrEmpty(configuredDir) && PathsEqual(configuredDir, pythonDir); + string configuredDir = McpConfigFileHelper.ExtractDirectoryArg(args); + bool matches = !string.IsNullOrEmpty(configuredDir) && McpConfigFileHelper.PathsEqual(configuredDir, pythonDir); if (matches) { mcpClient.SetStatus(McpStatus.Configured); From 345c06a2390f7f1f6878a5651a25323598f1dd39 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 14:13:32 -0400 Subject: [PATCH 03/12] feat: add TOML array parsing support for multi-line and trailing comma formats --- .../Assets/Tests/EditMode/Helpers.meta | 8 ++ .../Helpers/CodexConfigHelperTests.cs | 70 ++++++++++++ .../Helpers/CodexConfigHelperTests.cs.meta | 11 ++ .../Editor/Helpers/CodexConfigHelper.cs | 102 +++++++++++++++++- 4 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers.meta create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs create mode 100644 TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs.meta diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers.meta new file mode 100644 index 000000000..d9e9b560e --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d539787bf8f6a426e94bfffb32a36d4f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs new file mode 100644 index 000000000..ec40a2fba --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs @@ -0,0 +1,70 @@ +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); + } + } +} + diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs.meta new file mode 100644 index 000000000..9e043e4ff --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 013424dea29744a98b3dc01618f4e95e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs index 15164c95c..fd9575721 100644 --- a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs +++ b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Text; +using System.Text.RegularExpressions; using Newtonsoft.Json; namespace MCPForUnity.Editor.Helpers @@ -136,8 +137,9 @@ public static bool TryParseCodexServer(string toml, out string command, out stri int eq = trimmed.IndexOf('='); if (eq >= 0) { - string raw = trimmed[(eq + 1)..]; - args = ParseTomlStringArray(raw); + string raw = trimmed[(eq + 1)..].Trim(); + string aggregated = CollectTomlArray(raw, reader); + args = ParseTomlStringArray(aggregated); } } } @@ -145,6 +147,99 @@ public static bool TryParseCodexServer(string toml, out string command, out stri return !string.IsNullOrEmpty(command) && args != null; } + private static string CollectTomlArray(string firstSegment, StringReader reader) + { + StringBuilder buffer = new StringBuilder(); + string sanitizedFirst = StripTomlComment(firstSegment ?? string.Empty).Trim(); + buffer.Append(sanitizedFirst); + + if (IsTomlArrayComplete(buffer.ToString())) + { + return buffer.ToString(); + } + + string nextLine; + while ((nextLine = reader.ReadLine()) != null) + { + string sanitizedNext = StripTomlComment(nextLine).Trim(); + buffer.AppendLine(); + buffer.Append(sanitizedNext); + + if (IsTomlArrayComplete(buffer.ToString())) + { + break; + } + } + + return buffer.ToString(); + } + + private static bool IsTomlArrayComplete(string text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + + bool inDouble = false; + bool inSingle = false; + bool escape = false; + int depth = 0; + bool sawOpen = false; + + foreach (char c in text) + { + if (escape) + { + escape = false; + continue; + } + + if (c == '\\') + { + if (inDouble) + { + escape = true; + } + continue; + } + + if (c == '"' && !inSingle) + { + inDouble = !inDouble; + continue; + } + + if (c == '\'' && !inDouble) + { + inSingle = !inSingle; + continue; + } + + if (inDouble || inSingle) + { + continue; + } + + if (c == '[') + { + depth++; + sawOpen = true; + } + else if (c == ']') + { + if (depth > 0) + { + depth--; + } + } + } + + if (!sawOpen) return false; + + if (depth > 0) return false; + + int closingIndex = text.LastIndexOf(']'); + return closingIndex >= 0; + } + private static string FormatTomlStringArray(IEnumerable values) { if (values == null) return "[]"; @@ -192,6 +287,9 @@ private static string[] ParseTomlStringArray(string value) if (value == null) return null; string cleaned = StripTomlComment(value).Trim(); if (!cleaned.StartsWith("[") || !cleaned.EndsWith("]")) return null; + + cleaned = Regex.Replace(cleaned, @",(?=\s*\])", string.Empty); + try { return JsonConvert.DeserializeObject(cleaned); From a7e6822a75eb65b18cb209d530bd69fd68d5476d Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 14:34:31 -0400 Subject: [PATCH 04/12] fix: handle TOML inline comments in section headers during parsing --- .../EditMode/Helpers/CodexConfigHelperTests.cs | 18 +++++++++++++++++- .../Editor/Helpers/CodexConfigHelper.cs | 5 +++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs index ec40a2fba..995323eae 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs @@ -65,6 +65,22 @@ public void TryParseCodexServer_MultiLineArgsWithComments_IgnoresComments() 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); + } } } - diff --git a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs index fd9575721..08749e624 100644 --- a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs +++ b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs @@ -114,10 +114,11 @@ public static bool TryParseCodexServer(string toml, out string command, out stri string trimmed = line.Trim(); if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("#")) continue; - bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]") && !trimmed.StartsWith("[["); + string headerCandidate = StripTomlComment(trimmed).Trim(); + bool isSection = headerCandidate.StartsWith("[") && headerCandidate.EndsWith("]") && !headerCandidate.StartsWith("[["); if (isSection) { - inTarget = string.Equals(trimmed, "[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase); + inTarget = string.Equals(headerCandidate, "[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase); continue; } From 40f22a4728696a0128acfaa028ad8cedca679b76 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 15:26:07 -0400 Subject: [PATCH 05/12] fix: strip TOML comments before processing section headers --- UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs index 08749e624..44f1bb6bd 100644 --- a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs +++ b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs @@ -60,7 +60,8 @@ public static string UpsertCodexServerBlock(string existingToml, string newBlock bool replaced = false; while ((line = reader.ReadLine()) != null) { - string trimmed = line.Trim(); + string sanitizedLine = StripTomlComment(line); + string trimmed = sanitizedLine.Trim(); bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]") && !trimmed.StartsWith("[["); if (isSection) { From b4052f72deaa9f85462164d45ea9c079f5aff8a0 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 15:57:35 -0400 Subject: [PATCH 06/12] fix: improve JSON parsing to handle escaped single quotes in config strings --- UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs index 44f1bb6bd..8bd217adc 100644 --- a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs +++ b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs @@ -300,8 +300,12 @@ private static string[] ParseTomlStringArray(string value) { if (cleaned.IndexOf('"') < 0 && cleaned.IndexOf('\'') >= 0) { - string alt = cleaned.Replace('\'', '\"'); - try { return JsonConvert.DeserializeObject(alt); } catch { } + string normalized = Regex.Replace( + cleaned, + @"'((?:[^']|'')*)'", + m => JsonConvert.SerializeObject(m.Groups[1].Value.Replace("''", "'")) + ); + try { return JsonConvert.DeserializeObject(normalized); } catch { } } } return null; From 13cc716a4d0868b848fabd9b0bbdbb047ccf886e Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 16:21:05 -0400 Subject: [PATCH 07/12] Use Tommy for TOML parsing It's a single file and OSS, easy to integrate into Unity --- .../Helpers/CodexConfigHelperTests.cs | 17 + UnityMcpBridge/Editor/External.meta | 8 + UnityMcpBridge/Editor/External/Tommy.cs | 2099 +++++++++++++++++ UnityMcpBridge/Editor/External/Tommy.cs.meta | 11 + .../Editor/Helpers/CodexConfigHelper.cs | 276 +-- 5 files changed, 2205 insertions(+), 206 deletions(-) create mode 100644 UnityMcpBridge/Editor/External.meta create mode 100644 UnityMcpBridge/Editor/External/Tommy.cs create mode 100644 UnityMcpBridge/Editor/External/Tommy.cs.meta diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs index 995323eae..3ff6afd2b 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/CodexConfigHelperTests.cs @@ -82,5 +82,22 @@ public void TryParseCodexServer_HeaderWithComment_StillDetected() 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); + } } } diff --git a/UnityMcpBridge/Editor/External.meta b/UnityMcpBridge/Editor/External.meta new file mode 100644 index 000000000..ce757b156 --- /dev/null +++ b/UnityMcpBridge/Editor/External.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c11944bcfb9ec4576bab52874b7df584 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/External/Tommy.cs b/UnityMcpBridge/Editor/External/Tommy.cs new file mode 100644 index 000000000..08172e7d7 --- /dev/null +++ b/UnityMcpBridge/Editor/External/Tommy.cs @@ -0,0 +1,2099 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace MCPForUnity.External.Tommy +{ + #region TOML Nodes + + public abstract class TomlNode : IEnumerable + { + public virtual bool HasValue { get; } = false; + public virtual bool IsArray { get; } = false; + public virtual bool IsTable { get; } = false; + public virtual bool IsString { get; } = false; + public virtual bool IsInteger { get; } = false; + public virtual bool IsFloat { get; } = false; + public bool IsDateTime => IsDateTimeLocal || IsDateTimeOffset; + public virtual bool IsDateTimeLocal { get; } = false; + public virtual bool IsDateTimeOffset { get; } = false; + public virtual bool IsBoolean { get; } = false; + public virtual string Comment { get; set; } + public virtual int CollapseLevel { get; set; } + + public virtual TomlTable AsTable => this as TomlTable; + public virtual TomlString AsString => this as TomlString; + public virtual TomlInteger AsInteger => this as TomlInteger; + public virtual TomlFloat AsFloat => this as TomlFloat; + public virtual TomlBoolean AsBoolean => this as TomlBoolean; + public virtual TomlDateTimeLocal AsDateTimeLocal => this as TomlDateTimeLocal; + public virtual TomlDateTimeOffset AsDateTimeOffset => this as TomlDateTimeOffset; + public virtual TomlDateTime AsDateTime => this as TomlDateTime; + public virtual TomlArray AsArray => this as TomlArray; + + public virtual int ChildrenCount => 0; + + public virtual TomlNode this[string key] + { + get => null; + set { } + } + + public virtual TomlNode this[int index] + { + get => null; + set { } + } + + public virtual IEnumerable Children + { + get { yield break; } + } + + public virtual IEnumerable Keys + { + get { yield break; } + } + + public IEnumerator GetEnumerator() => Children.GetEnumerator(); + + public virtual bool TryGetNode(string key, out TomlNode node) + { + node = null; + return false; + } + + public virtual bool HasKey(string key) => false; + + public virtual bool HasItemAt(int index) => false; + + public virtual void Add(string key, TomlNode node) { } + + public virtual void Add(TomlNode node) { } + + public virtual void Delete(TomlNode node) { } + + public virtual void Delete(string key) { } + + public virtual void Delete(int index) { } + + public virtual void AddRange(IEnumerable nodes) + { + foreach (var tomlNode in nodes) Add(tomlNode); + } + + public virtual void WriteTo(TextWriter tw, string name = null) => tw.WriteLine(ToInlineToml()); + + public virtual string ToInlineToml() => ToString(); + + #region Native type to TOML cast + + public static implicit operator TomlNode(string value) => new TomlString {Value = value}; + + public static implicit operator TomlNode(bool value) => new TomlBoolean {Value = value}; + + public static implicit operator TomlNode(long value) => new TomlInteger {Value = value}; + + public static implicit operator TomlNode(float value) => new TomlFloat {Value = value}; + + public static implicit operator TomlNode(double value) => new TomlFloat {Value = value}; + + public static implicit operator TomlNode(DateTime value) => new TomlDateTimeLocal {Value = value}; + + public static implicit operator TomlNode(DateTimeOffset value) => new TomlDateTimeOffset {Value = value}; + + public static implicit operator TomlNode(TomlNode[] nodes) + { + var result = new TomlArray(); + result.AddRange(nodes); + return result; + } + + #endregion + + #region TOML to native type cast + + public static implicit operator string(TomlNode value) => value.ToString(); + + public static implicit operator int(TomlNode value) => (int) value.AsInteger.Value; + + public static implicit operator long(TomlNode value) => value.AsInteger.Value; + + public static implicit operator float(TomlNode value) => (float) value.AsFloat.Value; + + public static implicit operator double(TomlNode value) => value.AsFloat.Value; + + public static implicit operator bool(TomlNode value) => value.AsBoolean.Value; + + public static implicit operator DateTime(TomlNode value) => value.AsDateTimeLocal.Value; + + public static implicit operator DateTimeOffset(TomlNode value) => value.AsDateTimeOffset.Value; + + #endregion + } + + public class TomlString : TomlNode + { + public override bool HasValue { get; } = true; + public override bool IsString { get; } = true; + public bool IsMultiline { get; set; } + public bool MultilineTrimFirstLine { get; set; } + public bool PreferLiteral { get; set; } + + public string Value { get; set; } + + public override string ToString() => Value; + + public override string ToInlineToml() + { + // Automatically convert literal to non-literal if there are too many literal string symbols + if (Value.IndexOf(new string(TomlSyntax.LITERAL_STRING_SYMBOL, IsMultiline ? 3 : 1), StringComparison.Ordinal) != -1 && PreferLiteral) PreferLiteral = false; + var quotes = new string(PreferLiteral ? TomlSyntax.LITERAL_STRING_SYMBOL : TomlSyntax.BASIC_STRING_SYMBOL, + IsMultiline ? 3 : 1); + var result = PreferLiteral ? Value : Value.Escape(!IsMultiline); + if (IsMultiline) + result = result.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); + if (IsMultiline && (MultilineTrimFirstLine || !MultilineTrimFirstLine && result.StartsWith(Environment.NewLine))) + result = $"{Environment.NewLine}{result}"; + return $"{quotes}{result}{quotes}"; + } + } + + public class TomlInteger : TomlNode + { + public enum Base + { + Binary = 2, + Octal = 8, + Decimal = 10, + Hexadecimal = 16 + } + + public override bool IsInteger { get; } = true; + public override bool HasValue { get; } = true; + public Base IntegerBase { get; set; } = Base.Decimal; + + public long Value { get; set; } + + public override string ToString() => Value.ToString(); + + public override string ToInlineToml() => + IntegerBase != Base.Decimal + ? $"0{TomlSyntax.BaseIdentifiers[(int) IntegerBase]}{Convert.ToString(Value, (int) IntegerBase)}" + : Value.ToString(CultureInfo.InvariantCulture); + } + + public class TomlFloat : TomlNode, IFormattable + { + public override bool IsFloat { get; } = true; + public override bool HasValue { get; } = true; + + public double Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); + + public string ToString(string format, IFormatProvider formatProvider) => Value.ToString(format, formatProvider); + + public string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToInlineToml() => + Value switch + { + var v when double.IsNaN(v) => TomlSyntax.NAN_VALUE, + var v when double.IsPositiveInfinity(v) => TomlSyntax.INF_VALUE, + var v when double.IsNegativeInfinity(v) => TomlSyntax.NEG_INF_VALUE, + var v => v.ToString("G", CultureInfo.InvariantCulture).ToLowerInvariant() + }; + } + + public class TomlBoolean : TomlNode + { + public override bool IsBoolean { get; } = true; + public override bool HasValue { get; } = true; + + public bool Value { get; set; } + + public override string ToString() => Value.ToString(); + + public override string ToInlineToml() => Value ? TomlSyntax.TRUE_VALUE : TomlSyntax.FALSE_VALUE; + } + + public class TomlDateTime : TomlNode, IFormattable + { + public int SecondsPrecision { get; set; } + public override bool HasValue { get; } = true; + public virtual string ToString(string format, IFormatProvider formatProvider) => string.Empty; + public virtual string ToString(IFormatProvider formatProvider) => string.Empty; + protected virtual string ToInlineTomlInternal() => string.Empty; + + public override string ToInlineToml() => ToInlineTomlInternal() + .Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator) + .Replace(TomlSyntax.ISO861ZeroZone, TomlSyntax.RFC3339ZeroZone); + } + + public class TomlDateTimeOffset : TomlDateTime + { + public override bool IsDateTimeOffset { get; } = true; + public DateTimeOffset Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); + public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToString(string format, IFormatProvider formatProvider) => + Value.ToString(format, formatProvider); + + protected override string ToInlineTomlInternal() => Value.ToString(TomlSyntax.RFC3339Formats[SecondsPrecision]); + } + + public class TomlDateTimeLocal : TomlDateTime + { + public enum DateTimeStyle + { + Date, + Time, + DateTime + } + + public override bool IsDateTimeLocal { get; } = true; + public DateTimeStyle Style { get; set; } = DateTimeStyle.DateTime; + public DateTime Value { get; set; } + + public override string ToString() => Value.ToString(CultureInfo.CurrentCulture); + + public override string ToString(IFormatProvider formatProvider) => Value.ToString(formatProvider); + + public override string ToString(string format, IFormatProvider formatProvider) => + Value.ToString(format, formatProvider); + + public override string ToInlineToml() => + Style switch + { + DateTimeStyle.Date => Value.ToString(TomlSyntax.LocalDateFormat), + DateTimeStyle.Time => Value.ToString(TomlSyntax.RFC3339LocalTimeFormats[SecondsPrecision]), + var _ => Value.ToString(TomlSyntax.RFC3339LocalDateTimeFormats[SecondsPrecision]) + }; + } + + public class TomlArray : TomlNode + { + private List values; + + public override bool HasValue { get; } = true; + public override bool IsArray { get; } = true; + public bool IsMultiline { get; set; } + public bool IsTableArray { get; set; } + public List RawArray => values ??= new List(); + + public override TomlNode this[int index] + { + get + { + if (index < RawArray.Count) return RawArray[index]; + var lazy = new TomlLazy(this); + this[index] = lazy; + return lazy; + } + set + { + if (index == RawArray.Count) + RawArray.Add(value); + else + RawArray[index] = value; + } + } + + public override int ChildrenCount => RawArray.Count; + + public override IEnumerable Children => RawArray.AsEnumerable(); + + public override void Add(TomlNode node) => RawArray.Add(node); + + public override void AddRange(IEnumerable nodes) => RawArray.AddRange(nodes); + + public override void Delete(TomlNode node) => RawArray.Remove(node); + + public override void Delete(int index) => RawArray.RemoveAt(index); + + public override string ToString() => ToString(false); + + public string ToString(bool multiline) + { + var sb = new StringBuilder(); + sb.Append(TomlSyntax.ARRAY_START_SYMBOL); + if (ChildrenCount != 0) + { + var arrayStart = multiline ? $"{Environment.NewLine} " : " "; + var arraySeparator = multiline ? $"{TomlSyntax.ITEM_SEPARATOR}{Environment.NewLine} " : $"{TomlSyntax.ITEM_SEPARATOR} "; + var arrayEnd = multiline ? Environment.NewLine : " "; + sb.Append(arrayStart) + .Append(arraySeparator.Join(RawArray.Select(n => n.ToInlineToml()))) + .Append(arrayEnd); + } + sb.Append(TomlSyntax.ARRAY_END_SYMBOL); + return sb.ToString(); + } + + public override void WriteTo(TextWriter tw, string name = null) + { + // If it's a normal array, write it as usual + if (!IsTableArray) + { + tw.WriteLine(ToString(IsMultiline)); + return; + } + + if (Comment is not null) + { + tw.WriteLine(); + Comment.AsComment(tw); + } + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + + var first = true; + + foreach (var tomlNode in RawArray) + { + if (tomlNode is not TomlTable tbl) + throw new TomlFormatException("The array is marked as array table but contains non-table nodes!"); + + // Ensure it's parsed as a section + tbl.IsInline = false; + + if (!first) + { + tw.WriteLine(); + + Comment?.AsComment(tw); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + } + + first = false; + + // Don't write section since it's already written here + tbl.WriteTo(tw, name, false); + } + } + } + + public class TomlTable : TomlNode + { + private Dictionary children; + internal bool isImplicit; + + public override bool HasValue { get; } = false; + public override bool IsTable { get; } = true; + public bool IsInline { get; set; } + public Dictionary RawTable => children ??= new Dictionary(); + + public override TomlNode this[string key] + { + get + { + if (RawTable.TryGetValue(key, out var result)) return result; + var lazy = new TomlLazy(this); + RawTable[key] = lazy; + return lazy; + } + set => RawTable[key] = value; + } + + public override int ChildrenCount => RawTable.Count; + public override IEnumerable Children => RawTable.Select(kv => kv.Value); + public override IEnumerable Keys => RawTable.Select(kv => kv.Key); + public override bool HasKey(string key) => RawTable.ContainsKey(key); + public override void Add(string key, TomlNode node) => RawTable.Add(key, node); + public override bool TryGetNode(string key, out TomlNode node) => RawTable.TryGetValue(key, out node); + public override void Delete(TomlNode node) => RawTable.Remove(RawTable.First(kv => kv.Value == node).Key); + public override void Delete(string key) => RawTable.Remove(key); + + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append(TomlSyntax.INLINE_TABLE_START_SYMBOL); + + if (ChildrenCount != 0) + { + var collapsed = CollectCollapsedItems(normalizeOrder: false); + + if (collapsed.Count != 0) + sb.Append(' ') + .Append($"{TomlSyntax.ITEM_SEPARATOR} ".Join(collapsed.Select(n => + $"{n.Key} {TomlSyntax.KEY_VALUE_SEPARATOR} {n.Value.ToInlineToml()}"))); + sb.Append(' '); + } + + sb.Append(TomlSyntax.INLINE_TABLE_END_SYMBOL); + return sb.ToString(); + } + + private LinkedList> CollectCollapsedItems(string prefix = "", int level = 0, bool normalizeOrder = true) + { + var nodes = new LinkedList>(); + var postNodes = normalizeOrder ? new LinkedList>() : nodes; + + foreach (var keyValuePair in RawTable) + { + var node = keyValuePair.Value; + var key = keyValuePair.Key.AsKey(); + + if (node is TomlTable tbl) + { + var subnodes = tbl.CollectCollapsedItems($"{prefix}{key}.", level + 1, normalizeOrder); + // Write main table first before writing collapsed items + if (subnodes.Count == 0 && node.CollapseLevel == level) + { + postNodes.AddLast(new KeyValuePair($"{prefix}{key}", node)); + } + foreach (var kv in subnodes) + postNodes.AddLast(kv); + } + else if (node.CollapseLevel == level) + nodes.AddLast(new KeyValuePair($"{prefix}{key}", node)); + } + + if (normalizeOrder) + foreach (var kv in postNodes) + nodes.AddLast(kv); + + return nodes; + } + + public override void WriteTo(TextWriter tw, string name = null) => WriteTo(tw, name, true); + + internal void WriteTo(TextWriter tw, string name, bool writeSectionName) + { + // The table is inline table + if (IsInline && name != null) + { + tw.WriteLine(ToInlineToml()); + return; + } + + var collapsedItems = CollectCollapsedItems(); + + if (collapsedItems.Count == 0) + return; + + var hasRealValues = !collapsedItems.All(n => n.Value is TomlTable {IsInline: false} or TomlArray {IsTableArray: true}); + + Comment?.AsComment(tw); + + if (name != null && (hasRealValues || Comment != null) && writeSectionName) + { + tw.Write(TomlSyntax.ARRAY_START_SYMBOL); + tw.Write(name); + tw.Write(TomlSyntax.ARRAY_END_SYMBOL); + tw.WriteLine(); + } + else if (Comment != null) // Add some spacing between the first node and the comment + { + tw.WriteLine(); + } + + var namePrefix = name == null ? "" : $"{name}."; + var first = true; + + foreach (var collapsedItem in collapsedItems) + { + var key = collapsedItem.Key; + if (collapsedItem.Value is TomlArray {IsTableArray: true} or TomlTable {IsInline: false}) + { + if (!first) tw.WriteLine(); + first = false; + collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); + continue; + } + first = false; + + collapsedItem.Value.Comment?.AsComment(tw); + tw.Write(key); + tw.Write(' '); + tw.Write(TomlSyntax.KEY_VALUE_SEPARATOR); + tw.Write(' '); + + collapsedItem.Value.WriteTo(tw, $"{namePrefix}{key}"); + } + } + } + + internal class TomlLazy : TomlNode + { + private readonly TomlNode parent; + private TomlNode replacement; + + public TomlLazy(TomlNode parent) => this.parent = parent; + + public override TomlNode this[int index] + { + get => Set()[index]; + set => Set()[index] = value; + } + + public override TomlNode this[string key] + { + get => Set()[key]; + set => Set()[key] = value; + } + + public override void Add(TomlNode node) => Set().Add(node); + + public override void Add(string key, TomlNode node) => Set().Add(key, node); + + public override void AddRange(IEnumerable nodes) => Set().AddRange(nodes); + + private TomlNode Set() where T : TomlNode, new() + { + if (replacement != null) return replacement; + + var newNode = new T + { + Comment = Comment + }; + + if (parent.IsTable) + { + var key = parent.Keys.FirstOrDefault(s => parent.TryGetNode(s, out var node) && node.Equals(this)); + if (key == null) return default(T); + + parent[key] = newNode; + } + else if (parent.IsArray) + { + var index = parent.Children.TakeWhile(child => child != this).Count(); + if (index == parent.ChildrenCount) return default(T); + parent[index] = newNode; + } + else + { + return default(T); + } + + replacement = newNode; + return newNode; + } + } + + #endregion + + #region Parser + + public class TOMLParser : IDisposable + { + public enum ParseState + { + None, + KeyValuePair, + SkipToNextLine, + Table + } + + private readonly TextReader reader; + private ParseState currentState; + private int line, col; + private List syntaxErrors; + + public TOMLParser(TextReader reader) + { + this.reader = reader; + line = col = 0; + } + + public bool ForceASCII { get; set; } + + public void Dispose() => reader?.Dispose(); + + public TomlTable Parse() + { + syntaxErrors = new List(); + line = col = 1; + var rootNode = new TomlTable(); + var currentNode = rootNode; + currentState = ParseState.None; + var keyParts = new List(); + var arrayTable = false; + StringBuilder latestComment = null; + var firstComment = true; + + int currentChar; + while ((currentChar = reader.Peek()) >= 0) + { + var c = (char) currentChar; + + if (currentState == ParseState.None) + { + // Skip white space + if (TomlSyntax.IsWhiteSpace(c)) goto consume_character; + + if (TomlSyntax.IsNewLine(c)) + { + // Check if there are any comments and so far no items being declared + if (latestComment != null && firstComment) + { + rootNode.Comment = latestComment.ToString().TrimEnd(); + latestComment = null; + firstComment = false; + } + + if (TomlSyntax.IsLineBreak(c)) + AdvanceLine(); + + goto consume_character; + } + + // Start of a comment; ignore until newline + if (c == TomlSyntax.COMMENT_SYMBOL) + { + latestComment ??= new StringBuilder(); + latestComment.AppendLine(ParseComment()); + AdvanceLine(1); + continue; + } + + // Encountered a non-comment value. The comment must belong to it (ignore possible newlines)! + firstComment = false; + + if (c == TomlSyntax.TABLE_START_SYMBOL) + { + currentState = ParseState.Table; + goto consume_character; + } + + if (TomlSyntax.IsBareKey(c) || TomlSyntax.IsQuoted(c)) + { + currentState = ParseState.KeyValuePair; + } + else + { + AddError($"Unexpected character \"{c}\""); + continue; + } + } + + if (currentState == ParseState.KeyValuePair) + { + var keyValuePair = ReadKeyValuePair(keyParts); + + if (keyValuePair == null) + { + latestComment = null; + keyParts.Clear(); + + if (currentState != ParseState.None) + AddError("Failed to parse key-value pair!"); + continue; + } + + keyValuePair.Comment = latestComment?.ToString()?.TrimEnd(); + var inserted = InsertNode(keyValuePair, currentNode, keyParts); + latestComment = null; + keyParts.Clear(); + if (inserted) + currentState = ParseState.SkipToNextLine; + continue; + } + + if (currentState == ParseState.Table) + { + if (keyParts.Count == 0) + { + // We have array table + if (c == TomlSyntax.TABLE_START_SYMBOL) + { + // Consume the character + ConsumeChar(); + arrayTable = true; + } + + if (!ReadKeyName(ref keyParts, TomlSyntax.TABLE_END_SYMBOL)) + { + keyParts.Clear(); + continue; + } + + if (keyParts.Count == 0) + { + AddError("Table name is emtpy."); + arrayTable = false; + latestComment = null; + keyParts.Clear(); + } + + continue; + } + + if (c == TomlSyntax.TABLE_END_SYMBOL) + { + if (arrayTable) + { + // Consume the ending bracket so we can peek the next character + ConsumeChar(); + var nextChar = reader.Peek(); + if (nextChar < 0 || (char) nextChar != TomlSyntax.TABLE_END_SYMBOL) + { + AddError($"Array table {".".Join(keyParts)} has only one closing bracket."); + keyParts.Clear(); + arrayTable = false; + latestComment = null; + continue; + } + } + + currentNode = CreateTable(rootNode, keyParts, arrayTable); + if (currentNode != null) + { + currentNode.IsInline = false; + currentNode.Comment = latestComment?.ToString()?.TrimEnd(); + } + + keyParts.Clear(); + arrayTable = false; + latestComment = null; + + if (currentNode == null) + { + if (currentState != ParseState.None) + AddError("Error creating table array!"); + // Reset a node to root in order to try and continue parsing + currentNode = rootNode; + continue; + } + + currentState = ParseState.SkipToNextLine; + goto consume_character; + } + + if (keyParts.Count != 0) + { + AddError($"Unexpected character \"{c}\""); + keyParts.Clear(); + arrayTable = false; + latestComment = null; + } + } + + if (currentState == ParseState.SkipToNextLine) + { + if (TomlSyntax.IsWhiteSpace(c) || c == TomlSyntax.NEWLINE_CARRIAGE_RETURN_CHARACTER) + goto consume_character; + + if (c is TomlSyntax.COMMENT_SYMBOL or TomlSyntax.NEWLINE_CHARACTER) + { + currentState = ParseState.None; + AdvanceLine(); + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + col++; + ParseComment(); + continue; + } + + goto consume_character; + } + + AddError($"Unexpected character \"{c}\" at the end of the line."); + } + + consume_character: + reader.Read(); + col++; + } + + if (currentState != ParseState.None && currentState != ParseState.SkipToNextLine) + AddError("Unexpected end of file!"); + + if (syntaxErrors.Count > 0) + throw new TomlParseException(rootNode, syntaxErrors); + + return rootNode; + } + + private bool AddError(string message, bool skipLine = true) + { + syntaxErrors.Add(new TomlSyntaxException(message, currentState, line, col)); + // Skip the whole line in hope that it was only a single faulty value (and non-multiline one at that) + if (skipLine) + { + reader.ReadLine(); + AdvanceLine(1); + } + currentState = ParseState.None; + return false; + } + + private void AdvanceLine(int startCol = 0) + { + line++; + col = startCol; + } + + private int ConsumeChar() + { + col++; + return reader.Read(); + } + + #region Key-Value pair parsing + + /** + * Reads a single key-value pair. + * Assumes the cursor is at the first character that belong to the pair (including possible whitespace). + * Consumes all characters that belong to the key and the value (ignoring possible trailing whitespace at the end). + * + * Example: + * foo = "bar" ==> foo = "bar" + * ^ ^ + */ + private TomlNode ReadKeyValuePair(List keyParts) + { + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + + if (TomlSyntax.IsQuoted(c) || TomlSyntax.IsBareKey(c)) + { + if (keyParts.Count != 0) + { + AddError("Encountered extra characters in key definition!"); + return null; + } + + if (!ReadKeyName(ref keyParts, TomlSyntax.KEY_VALUE_SEPARATOR)) + return null; + + continue; + } + + if (TomlSyntax.IsWhiteSpace(c)) + { + ConsumeChar(); + continue; + } + + if (c == TomlSyntax.KEY_VALUE_SEPARATOR) + { + ConsumeChar(); + return ReadValue(); + } + + AddError($"Unexpected character \"{c}\" in key name."); + return null; + } + + return null; + } + + /** + * Reads a single value. + * Assumes the cursor is at the first character that belongs to the value (including possible starting whitespace). + * Consumes all characters belonging to the value (ignoring possible trailing whitespace at the end). + * + * Example: + * "test" ==> "test" + * ^ ^ + */ + private TomlNode ReadValue(bool skipNewlines = false) + { + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + + if (TomlSyntax.IsWhiteSpace(c)) + { + ConsumeChar(); + continue; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + AddError("No value found!"); + return null; + } + + if (TomlSyntax.IsNewLine(c)) + { + if (skipNewlines) + { + reader.Read(); + AdvanceLine(1); + continue; + } + + AddError("Encountered a newline when expecting a value!"); + return null; + } + + if (TomlSyntax.IsQuoted(c)) + { + var isMultiline = IsTripleQuote(c, out var excess); + + // Error occurred in triple quote parsing + if (currentState == ParseState.None) + return null; + + var value = isMultiline + ? ReadQuotedValueMultiLine(c) + : ReadQuotedValueSingleLine(c, excess); + + if (value is null) + return null; + + return new TomlString + { + Value = value, + IsMultiline = isMultiline, + PreferLiteral = c == TomlSyntax.LITERAL_STRING_SYMBOL + }; + } + + return c switch + { + TomlSyntax.INLINE_TABLE_START_SYMBOL => ReadInlineTable(), + TomlSyntax.ARRAY_START_SYMBOL => ReadArray(), + var _ => ReadTomlValue() + }; + } + + return null; + } + + /** + * Reads a single key name. + * Assumes the cursor is at the first character belonging to the key (with possible trailing whitespace if `skipWhitespace = true`). + * Consumes all the characters until the `until` character is met (but does not consume the character itself). + * + * Example 1: + * foo.bar ==> foo.bar (`skipWhitespace = false`, `until = ' '`) + * ^ ^ + * + * Example 2: + * [ foo . bar ] ==> [ foo . bar ] (`skipWhitespace = true`, `until = ']'`) + * ^ ^ + */ + private bool ReadKeyName(ref List parts, char until) + { + var buffer = new StringBuilder(); + var quoted = false; + var prevWasSpace = false; + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + + // Reached the final character + if (c == until) break; + + if (TomlSyntax.IsWhiteSpace(c)) + { + prevWasSpace = true; + goto consume_character; + } + + if (buffer.Length == 0) prevWasSpace = false; + + if (c == TomlSyntax.SUBKEY_SEPARATOR) + { + if (buffer.Length == 0 && !quoted) + return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); + + parts.Add(buffer.ToString()); + buffer.Length = 0; + quoted = false; + prevWasSpace = false; + goto consume_character; + } + + if (prevWasSpace) + return AddError("Invalid spacing in key name"); + + if (TomlSyntax.IsQuoted(c)) + { + if (quoted) + + return AddError("Expected a subkey separator but got extra data instead!"); + + if (buffer.Length != 0) + return AddError("Encountered a quote in the middle of subkey name!"); + + // Consume the quote character and read the key name + col++; + buffer.Append(ReadQuotedValueSingleLine((char) reader.Read())); + quoted = true; + continue; + } + + if (TomlSyntax.IsBareKey(c)) + { + buffer.Append(c); + goto consume_character; + } + + // If we see an invalid symbol, let the next parser handle it + break; + + consume_character: + reader.Read(); + col++; + } + + if (buffer.Length == 0 && !quoted) + return AddError($"Found an extra subkey separator in {".".Join(parts)}..."); + + parts.Add(buffer.ToString()); + + return true; + } + + #endregion + + #region Non-string value parsing + + /** + * Reads the whole raw value until the first non-value character is encountered. + * Assumes the cursor start position at the first value character and consumes all characters that may be related to the value. + * Example: + * + * 1_0_0_0 ==> 1_0_0_0 + * ^ ^ + */ + private string ReadRawValue() + { + var result = new StringBuilder(); + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + if (c == TomlSyntax.COMMENT_SYMBOL || TomlSyntax.IsNewLine(c) || TomlSyntax.IsValueSeparator(c)) break; + result.Append(c); + ConsumeChar(); + } + + // Replace trim with manual space counting? + return result.ToString().Trim(); + } + + /** + * Reads and parses a non-string, non-composite TOML value. + * Assumes the cursor at the first character that is related to the value (with possible spaces). + * Consumes all the characters that are related to the value. + * + * Example + * 1_0_0_0 # This is a comment + * + * ==> 1_0_0_0 # This is a comment + * ^ ^ + */ + private TomlNode ReadTomlValue() + { + var value = ReadRawValue(); + TomlNode node = value switch + { + var v when TomlSyntax.IsBoolean(v) => bool.Parse(v), + var v when TomlSyntax.IsNaN(v) => double.NaN, + var v when TomlSyntax.IsPosInf(v) => double.PositiveInfinity, + var v when TomlSyntax.IsNegInf(v) => double.NegativeInfinity, + var v when TomlSyntax.IsInteger(v) => long.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), + CultureInfo.InvariantCulture), + var v when TomlSyntax.IsFloat(v) => double.Parse(value.RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), + CultureInfo.InvariantCulture), + var v when TomlSyntax.IsIntegerWithBase(v, out var numberBase) => new TomlInteger + { + Value = Convert.ToInt64(value.Substring(2).RemoveAll(TomlSyntax.INT_NUMBER_SEPARATOR), numberBase), + IntegerBase = (TomlInteger.Base) numberBase + }, + var _ => null + }; + if (node != null) return node; + + // Normalize by removing space separator + value = value.Replace(TomlSyntax.RFC3339EmptySeparator, TomlSyntax.ISO861Separator); + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339LocalDateTimeFormats, + DateTimeStyles.AssumeLocal, + DateTime.TryParseExact, + out var dateTimeResult, + out var precision)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + SecondsPrecision = precision + }; + + if (DateTime.TryParseExact(value, + TomlSyntax.LocalDateFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out dateTimeResult)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + Style = TomlDateTimeLocal.DateTimeStyle.Date + }; + + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339LocalTimeFormats, + DateTimeStyles.AssumeLocal, + DateTime.TryParseExact, + out dateTimeResult, + out precision)) + return new TomlDateTimeLocal + { + Value = dateTimeResult, + Style = TomlDateTimeLocal.DateTimeStyle.Time, + SecondsPrecision = precision + }; + + if (StringUtils.TryParseDateTime(value, + TomlSyntax.RFC3339Formats, + DateTimeStyles.None, + DateTimeOffset.TryParseExact, + out var dateTimeOffsetResult, + out precision)) + return new TomlDateTimeOffset + { + Value = dateTimeOffsetResult, + SecondsPrecision = precision + }; + + AddError($"Value \"{value}\" is not a valid TOML value!"); + return null; + } + + /** + * Reads an array value. + * Assumes the cursor is at the start of the array definition. Reads all character until the array closing bracket. + * + * Example: + * [1, 2, 3] ==> [1, 2, 3] + * ^ ^ + */ + private TomlArray ReadArray() + { + // Consume the start of array character + ConsumeChar(); + var result = new TomlArray(); + TomlNode currentValue = null; + var expectValue = true; + + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + + if (c == TomlSyntax.ARRAY_END_SYMBOL) + { + ConsumeChar(); + break; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + reader.ReadLine(); + AdvanceLine(1); + continue; + } + + if (TomlSyntax.IsWhiteSpace(c) || TomlSyntax.IsNewLine(c)) + { + if (TomlSyntax.IsLineBreak(c)) + AdvanceLine(); + goto consume_character; + } + + if (c == TomlSyntax.ITEM_SEPARATOR) + { + if (currentValue == null) + { + AddError("Encountered multiple value separators"); + return null; + } + + result.Add(currentValue); + currentValue = null; + expectValue = true; + goto consume_character; + } + + if (!expectValue) + { + AddError("Missing separator between values"); + return null; + } + currentValue = ReadValue(true); + if (currentValue == null) + { + if (currentState != ParseState.None) + AddError("Failed to determine and parse a value!"); + return null; + } + expectValue = false; + + continue; + consume_character: + ConsumeChar(); + } + + if (currentValue != null) result.Add(currentValue); + return result; + } + + /** + * Reads an inline table. + * Assumes the cursor is at the start of the table definition. Reads all character until the table closing bracket. + * + * Example: + * { test = "foo", value = 1 } ==> { test = "foo", value = 1 } + * ^ ^ + */ + private TomlNode ReadInlineTable() + { + ConsumeChar(); + var result = new TomlTable {IsInline = true}; + TomlNode currentValue = null; + var separator = false; + var keyParts = new List(); + int cur; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + + if (c == TomlSyntax.INLINE_TABLE_END_SYMBOL) + { + ConsumeChar(); + break; + } + + if (c == TomlSyntax.COMMENT_SYMBOL) + { + AddError("Incomplete inline table definition!"); + return null; + } + + if (TomlSyntax.IsNewLine(c)) + { + AddError("Inline tables are only allowed to be on single line"); + return null; + } + + if (TomlSyntax.IsWhiteSpace(c)) + goto consume_character; + + if (c == TomlSyntax.ITEM_SEPARATOR) + { + if (currentValue == null) + { + AddError("Encountered multiple value separators in inline table!"); + return null; + } + + if (!InsertNode(currentValue, result, keyParts)) + return null; + keyParts.Clear(); + currentValue = null; + separator = true; + goto consume_character; + } + + separator = false; + currentValue = ReadKeyValuePair(keyParts); + continue; + + consume_character: + ConsumeChar(); + } + + if (separator) + { + AddError("Trailing commas are not allowed in inline tables."); + return null; + } + + if (currentValue != null && !InsertNode(currentValue, result, keyParts)) + return null; + + return result; + } + + #endregion + + #region String parsing + + /** + * Checks if the string value a multiline string (i.e. a triple quoted string). + * Assumes the cursor is at the first quote character. Consumes the least amount of characters needed to determine if the string is multiline. + * + * If the result is false, returns the consumed character through the `excess` variable. + * + * Example 1: + * """test""" ==> """test""" + * ^ ^ + * + * Example 2: + * "test" ==> "test" (doesn't return the first quote) + * ^ ^ + * + * Example 3: + * "" ==> "" (returns the extra `"` through the `excess` variable) + * ^ ^ + */ + private bool IsTripleQuote(char quote, out char excess) + { + // Copypasta, but it's faster... + + int cur; + // Consume the first quote + ConsumeChar(); + if ((cur = reader.Peek()) < 0) + { + excess = '\0'; + return AddError("Unexpected end of file!"); + } + + if ((char) cur != quote) + { + excess = '\0'; + return false; + } + + // Consume the second quote + excess = (char) ConsumeChar(); + if ((cur = reader.Peek()) < 0 || (char) cur != quote) return false; + + // Consume the final quote + ConsumeChar(); + excess = '\0'; + return true; + } + + /** + * A convenience method to process a single character within a quote. + */ + private bool ProcessQuotedValueCharacter(char quote, + bool isNonLiteral, + char c, + StringBuilder sb, + ref bool escaped) + { + if (TomlSyntax.MustBeEscaped(c)) + return AddError($"The character U+{(int) c:X8} must be escaped in a string!"); + + if (escaped) + { + sb.Append(c); + escaped = false; + return false; + } + + if (c == quote) return true; + if (isNonLiteral && c == TomlSyntax.ESCAPE_SYMBOL) + escaped = true; + if (c == TomlSyntax.NEWLINE_CHARACTER) + return AddError("Encountered newline in single line string!"); + + sb.Append(c); + return false; + } + + /** + * Reads a single-line string. + * Assumes the cursor is at the first character that belongs to the string. + * Consumes all characters that belong to the string (including the closing quote). + * + * Example: + * "test" ==> "test" + * ^ ^ + */ + private string ReadQuotedValueSingleLine(char quote, char initialData = '\0') + { + var isNonLiteral = quote == TomlSyntax.BASIC_STRING_SYMBOL; + var sb = new StringBuilder(); + var escaped = false; + + if (initialData != '\0') + { + var shouldReturn = + ProcessQuotedValueCharacter(quote, isNonLiteral, initialData, sb, ref escaped); + if (currentState == ParseState.None) return null; + if (shouldReturn) + if (isNonLiteral) + { + if (sb.ToString().TryUnescape(out var res, out var ex)) return res; + AddError(ex.Message); + return null; + } + else + return sb.ToString(); + } + + int cur; + var readDone = false; + while ((cur = reader.Read()) >= 0) + { + // Consume the character + col++; + var c = (char) cur; + readDone = ProcessQuotedValueCharacter(quote, isNonLiteral, c, sb, ref escaped); + if (readDone) + { + if (currentState == ParseState.None) return null; + break; + } + } + + if (!readDone) + { + AddError("Unclosed string."); + return null; + } + + if (!isNonLiteral) return sb.ToString(); + if (sb.ToString().TryUnescape(out var unescaped, out var unescapedEx)) return unescaped; + AddError(unescapedEx.Message); + return null; + } + + /** + * Reads a multiline string. + * Assumes the cursor is at the first character that belongs to the string. + * Consumes all characters that belong to the string and the three closing quotes. + * + * Example: + * """test""" ==> """test""" + * ^ ^ + */ + private string ReadQuotedValueMultiLine(char quote) + { + var isBasic = quote == TomlSyntax.BASIC_STRING_SYMBOL; + var sb = new StringBuilder(); + var escaped = false; + var skipWhitespace = false; + var skipWhitespaceLineSkipped = false; + var quotesEncountered = 0; + var first = true; + int cur; + while ((cur = ConsumeChar()) >= 0) + { + var c = (char) cur; + if (TomlSyntax.MustBeEscaped(c, true)) + { + AddError($"The character U+{(int) c:X8} must be escaped!"); + return null; + } + // Trim the first newline + if (first && TomlSyntax.IsNewLine(c)) + { + if (TomlSyntax.IsLineBreak(c)) + first = false; + else + AdvanceLine(); + continue; + } + + first = false; + //TODO: Reuse ProcessQuotedValueCharacter + // Skip the current character if it is going to be escaped later + if (escaped) + { + sb.Append(c); + escaped = false; + continue; + } + + // If we are currently skipping empty spaces, skip + if (skipWhitespace) + { + if (TomlSyntax.IsEmptySpace(c)) + { + if (TomlSyntax.IsLineBreak(c)) + { + skipWhitespaceLineSkipped = true; + AdvanceLine(); + } + continue; + } + + if (!skipWhitespaceLineSkipped) + { + AddError("Non-whitespace character after trim marker."); + return null; + } + + skipWhitespaceLineSkipped = false; + skipWhitespace = false; + } + + // If we encounter an escape sequence... + if (isBasic && c == TomlSyntax.ESCAPE_SYMBOL) + { + var next = reader.Peek(); + var nc = (char) next; + if (next >= 0) + { + // ...and the next char is empty space, we must skip all whitespaces + if (TomlSyntax.IsEmptySpace(nc)) + { + skipWhitespace = true; + continue; + } + + // ...and we have \" or \, skip the character + if (nc == quote || nc == TomlSyntax.ESCAPE_SYMBOL) escaped = true; + } + } + + // Count the consecutive quotes + if (c == quote) + quotesEncountered++; + else + quotesEncountered = 0; + + // If the are three quotes, count them as closing quotes + if (quotesEncountered == 3) break; + + sb.Append(c); + } + + // TOML actually allows to have five ending quotes like + // """"" => "" belong to the string + """ is the actual ending + quotesEncountered = 0; + while ((cur = reader.Peek()) >= 0) + { + var c = (char) cur; + if (c == quote && ++quotesEncountered < 3) + { + sb.Append(c); + ConsumeChar(); + } + else break; + } + + // Remove last two quotes (third one wasn't included by default) + sb.Length -= 2; + if (!isBasic) return sb.ToString(); + if (sb.ToString().TryUnescape(out var res, out var ex)) return res; + AddError(ex.Message); + return null; + } + + #endregion + + #region Node creation + + private bool InsertNode(TomlNode node, TomlNode root, IList path) + { + var latestNode = root; + if (path.Count > 1) + for (var index = 0; index < path.Count - 1; index++) + { + var subkey = path[index]; + if (latestNode.TryGetNode(subkey, out var currentNode)) + { + if (currentNode.HasValue) + return AddError($"The key {".".Join(path)} already has a value assigned to it!"); + } + else + { + currentNode = new TomlTable(); + latestNode[subkey] = currentNode; + } + + latestNode = currentNode; + if (latestNode is TomlTable { IsInline: true }) + return AddError($"Cannot assign {".".Join(path)} because it will edit an immutable table."); + } + + if (latestNode.HasKey(path[path.Count - 1])) + return AddError($"The key {".".Join(path)} is already defined!"); + latestNode[path[path.Count - 1]] = node; + node.CollapseLevel = path.Count - 1; + return true; + } + + private TomlTable CreateTable(TomlNode root, IList path, bool arrayTable) + { + if (path.Count == 0) return null; + var latestNode = root; + for (var index = 0; index < path.Count; index++) + { + var subkey = path[index]; + + if (latestNode.TryGetNode(subkey, out var node)) + { + if (node.IsArray && arrayTable) + { + var arr = (TomlArray) node; + + if (!arr.IsTableArray) + { + AddError($"The array {".".Join(path)} cannot be redefined as an array table!"); + return null; + } + + if (index == path.Count - 1) + { + latestNode = new TomlTable(); + arr.Add(latestNode); + break; + } + + latestNode = arr[arr.ChildrenCount - 1]; + continue; + } + + if (node is TomlTable { IsInline: true }) + { + AddError($"Cannot create table {".".Join(path)} because it will edit an immutable table."); + return null; + } + + if (node.HasValue) + { + if (node is not TomlArray { IsTableArray: true } array) + { + AddError($"The key {".".Join(path)} has a value assigned to it!"); + return null; + } + + latestNode = array[array.ChildrenCount - 1]; + continue; + } + + if (index == path.Count - 1) + { + if (arrayTable && !node.IsArray) + { + AddError($"The table {".".Join(path)} cannot be redefined as an array table!"); + return null; + } + + if (node is TomlTable { isImplicit: false }) + { + AddError($"The table {".".Join(path)} is defined multiple times!"); + return null; + } + } + } + else + { + if (index == path.Count - 1 && arrayTable) + { + var table = new TomlTable(); + var arr = new TomlArray + { + IsTableArray = true + }; + arr.Add(table); + latestNode[subkey] = arr; + latestNode = table; + break; + } + + node = new TomlTable { isImplicit = true }; + latestNode[subkey] = node; + } + + latestNode = node; + } + + var result = (TomlTable) latestNode; + result.isImplicit = false; + return result; + } + + #endregion + + #region Misc parsing + + private string ParseComment() + { + ConsumeChar(); + var commentLine = reader.ReadLine()?.Trim() ?? ""; + if (commentLine.Any(ch => TomlSyntax.MustBeEscaped(ch))) + AddError("Comment must not contain control characters other than tab.", false); + return commentLine; + } + #endregion + } + + #endregion + + public static class TOML + { + public static bool ForceASCII { get; set; } = false; + + public static TomlTable Parse(TextReader reader) + { + using var parser = new TOMLParser(reader) {ForceASCII = ForceASCII}; + return parser.Parse(); + } + } + + #region Exception Types + + public class TomlFormatException : Exception + { + public TomlFormatException(string message) : base(message) { } + } + + public class TomlParseException : Exception + { + public TomlParseException(TomlTable parsed, IEnumerable exceptions) : + base("TOML file contains format errors") + { + ParsedTable = parsed; + SyntaxErrors = exceptions; + } + + public TomlTable ParsedTable { get; } + + public IEnumerable SyntaxErrors { get; } + } + + public class TomlSyntaxException : Exception + { + public TomlSyntaxException(string message, TOMLParser.ParseState state, int line, int col) : base(message) + { + ParseState = state; + Line = line; + Column = col; + } + + public TOMLParser.ParseState ParseState { get; } + + public int Line { get; } + + public int Column { get; } + } + + #endregion + + #region Parse utilities + + internal static class TomlSyntax + { + #region Type Patterns + + public const string TRUE_VALUE = "true"; + public const string FALSE_VALUE = "false"; + public const string NAN_VALUE = "nan"; + public const string POS_NAN_VALUE = "+nan"; + public const string NEG_NAN_VALUE = "-nan"; + public const string INF_VALUE = "inf"; + public const string POS_INF_VALUE = "+inf"; + public const string NEG_INF_VALUE = "-inf"; + + public static bool IsBoolean(string s) => s is TRUE_VALUE or FALSE_VALUE; + + public static bool IsPosInf(string s) => s is INF_VALUE or POS_INF_VALUE; + + public static bool IsNegInf(string s) => s == NEG_INF_VALUE; + + public static bool IsNaN(string s) => s is NAN_VALUE or POS_NAN_VALUE or NEG_NAN_VALUE; + + public static bool IsInteger(string s) => IntegerPattern.IsMatch(s); + + public static bool IsFloat(string s) => FloatPattern.IsMatch(s); + + public static bool IsIntegerWithBase(string s, out int numberBase) + { + numberBase = 10; + var match = BasedIntegerPattern.Match(s); + if (!match.Success) return false; + IntegerBases.TryGetValue(match.Groups["base"].Value, out numberBase); + return true; + } + + /** + * A pattern to verify the integer value according to the TOML specification. + */ + public static readonly Regex IntegerPattern = + new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)*)$", RegexOptions.Compiled); + + /** + * A pattern to verify a special 0x, 0o and 0b forms of an integer according to the TOML specification. + */ + public static readonly Regex BasedIntegerPattern = + new(@"^0(?x|b|o)(?!_)(_?[0-9A-F])*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /** + * A pattern to verify the float value according to the TOML specification. + */ + public static readonly Regex FloatPattern = + new(@"^(\+|-)?(?!_)(0|(?!0)(_?\d)+)(((e(\+|-)?(?!_)(_?\d)+)?)|(\.(?!_)(_?\d)+(e(\+|-)?(?!_)(_?\d)+)?))$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /** + * A helper dictionary to map TOML base codes into the radii. + */ + public static readonly Dictionary IntegerBases = new() + { + ["x"] = 16, + ["o"] = 8, + ["b"] = 2 + }; + + /** + * A helper dictionary to map non-decimal bases to their TOML identifiers + */ + public static readonly Dictionary BaseIdentifiers = new() + { + [2] = "b", + [8] = "o", + [16] = "x" + }; + + public const string RFC3339EmptySeparator = " "; + public const string ISO861Separator = "T"; + public const string ISO861ZeroZone = "+00:00"; + public const string RFC3339ZeroZone = "Z"; + + /** + * Valid date formats with timezone as per RFC3339. + */ + public static readonly string[] RFC3339Formats = + { + "yyyy'-'MM-ddTHH':'mm':'ssK", "yyyy'-'MM-ddTHH':'mm':'ss'.'fK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffK", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffffK", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffffK" + }; + + /** + * Valid date formats without timezone (assumes local) as per RFC3339. + */ + public static readonly string[] RFC3339LocalDateTimeFormats = + { + "yyyy'-'MM-ddTHH':'mm':'ss", "yyyy'-'MM-ddTHH':'mm':'ss'.'f", "yyyy'-'MM-ddTHH':'mm':'ss'.'ff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffff", "yyyy'-'MM-ddTHH':'mm':'ss'.'ffffff", + "yyyy'-'MM-ddTHH':'mm':'ss'.'fffffff" + }; + + /** + * Valid full date format as per TOML spec. + */ + public static readonly string LocalDateFormat = "yyyy'-'MM'-'dd"; + + /** + * Valid time formats as per TOML spec. + */ + public static readonly string[] RFC3339LocalTimeFormats = + { + "HH':'mm':'ss", "HH':'mm':'ss'.'f", "HH':'mm':'ss'.'ff", "HH':'mm':'ss'.'fff", "HH':'mm':'ss'.'ffff", + "HH':'mm':'ss'.'fffff", "HH':'mm':'ss'.'ffffff", "HH':'mm':'ss'.'fffffff" + }; + + #endregion + + #region Character definitions + + public const char ARRAY_END_SYMBOL = ']'; + public const char ITEM_SEPARATOR = ','; + public const char ARRAY_START_SYMBOL = '['; + public const char BASIC_STRING_SYMBOL = '\"'; + public const char COMMENT_SYMBOL = '#'; + public const char ESCAPE_SYMBOL = '\\'; + public const char KEY_VALUE_SEPARATOR = '='; + public const char NEWLINE_CARRIAGE_RETURN_CHARACTER = '\r'; + public const char NEWLINE_CHARACTER = '\n'; + public const char SUBKEY_SEPARATOR = '.'; + public const char TABLE_END_SYMBOL = ']'; + public const char TABLE_START_SYMBOL = '['; + public const char INLINE_TABLE_START_SYMBOL = '{'; + public const char INLINE_TABLE_END_SYMBOL = '}'; + public const char LITERAL_STRING_SYMBOL = '\''; + public const char INT_NUMBER_SEPARATOR = '_'; + + public static readonly char[] NewLineCharacters = {NEWLINE_CHARACTER, NEWLINE_CARRIAGE_RETURN_CHARACTER}; + + public static bool IsQuoted(char c) => c is BASIC_STRING_SYMBOL or LITERAL_STRING_SYMBOL; + + public static bool IsWhiteSpace(char c) => c is ' ' or '\t'; + + public static bool IsNewLine(char c) => c is NEWLINE_CHARACTER or NEWLINE_CARRIAGE_RETURN_CHARACTER; + + public static bool IsLineBreak(char c) => c == NEWLINE_CHARACTER; + + public static bool IsEmptySpace(char c) => IsWhiteSpace(c) || IsNewLine(c); + + public static bool IsBareKey(char c) => + c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-'; + + public static bool MustBeEscaped(char c, bool allowNewLines = false) + { + var result = c is (>= '\u0000' and <= '\u0008') or '\u000b' or '\u000c' or (>= '\u000e' and <= '\u001f') or '\u007f'; + if (!allowNewLines) + result |= c is >= '\u000a' and <= '\u000e'; + return result; + } + + public static bool IsValueSeparator(char c) => + c is ITEM_SEPARATOR or ARRAY_END_SYMBOL or INLINE_TABLE_END_SYMBOL; + + #endregion + } + + internal static class StringUtils + { + public static string AsKey(this string key) + { + var quote = key == string.Empty || key.Any(c => !TomlSyntax.IsBareKey(c)); + return !quote ? key : $"{TomlSyntax.BASIC_STRING_SYMBOL}{key.Escape()}{TomlSyntax.BASIC_STRING_SYMBOL}"; + } + + public static string Join(this string self, IEnumerable subItems) + { + var sb = new StringBuilder(); + var first = true; + + foreach (var subItem in subItems) + { + if (!first) sb.Append(self); + first = false; + sb.Append(subItem); + } + + return sb.ToString(); + } + + public delegate bool TryDateParseDelegate(string s, string format, IFormatProvider ci, DateTimeStyles dts, out T dt); + + public static bool TryParseDateTime(string s, + string[] formats, + DateTimeStyles styles, + TryDateParseDelegate parser, + out T dateTime, + out int parsedFormat) + { + parsedFormat = 0; + dateTime = default; + for (var i = 0; i < formats.Length; i++) + { + var format = formats[i]; + if (!parser(s, format, CultureInfo.InvariantCulture, styles, out dateTime)) continue; + parsedFormat = i; + return true; + } + + return false; + } + + public static void AsComment(this string self, TextWriter tw) + { + foreach (var line in self.Split(TomlSyntax.NEWLINE_CHARACTER)) + tw.WriteLine($"{TomlSyntax.COMMENT_SYMBOL} {line.Trim()}"); + } + + public static string RemoveAll(this string txt, char toRemove) + { + var sb = new StringBuilder(txt.Length); + foreach (var c in txt.Where(c => c != toRemove)) + sb.Append(c); + return sb.ToString(); + } + + public static string Escape(this string txt, bool escapeNewlines = true) + { + var stringBuilder = new StringBuilder(txt.Length + 2); + for (var i = 0; i < txt.Length; i++) + { + var c = txt[i]; + + static string CodePoint(string txt, ref int i, char c) => char.IsSurrogatePair(txt, i) + ? $"\\U{char.ConvertToUtf32(txt, i++):X8}" + : $"\\u{(ushort) c:X4}"; + + stringBuilder.Append(c switch + { + '\b' => @"\b", + '\t' => @"\t", + '\n' when escapeNewlines => @"\n", + '\f' => @"\f", + '\r' when escapeNewlines => @"\r", + '\\' => @"\\", + '\"' => @"\""", + var _ when TomlSyntax.MustBeEscaped(c, !escapeNewlines) || TOML.ForceASCII && c > sbyte.MaxValue => + CodePoint(txt, ref i, c), + var _ => c + }); + } + + return stringBuilder.ToString(); + } + + public static bool TryUnescape(this string txt, out string unescaped, out Exception exception) + { + try + { + exception = null; + unescaped = txt.Unescape(); + return true; + } + catch (Exception e) + { + exception = e; + unescaped = null; + return false; + } + } + + public static string Unescape(this string txt) + { + if (string.IsNullOrEmpty(txt)) return txt; + var stringBuilder = new StringBuilder(txt.Length); + for (var i = 0; i < txt.Length;) + { + var num = txt.IndexOf('\\', i); + var next = num + 1; + if (num < 0 || num == txt.Length - 1) num = txt.Length; + stringBuilder.Append(txt, i, num - i); + if (num >= txt.Length) break; + var c = txt[next]; + + static string CodePoint(int next, string txt, ref int num, int size) + { + if (next + size >= txt.Length) throw new Exception("Undefined escape sequence!"); + num += size; + return char.ConvertFromUtf32(Convert.ToInt32(txt.Substring(next + 1, size), 16)); + } + + stringBuilder.Append(c switch + { + 'b' => "\b", + 't' => "\t", + 'n' => "\n", + 'f' => "\f", + 'r' => "\r", + '\'' => "\'", + '\"' => "\"", + '\\' => "\\", + 'u' => CodePoint(next, txt, ref num, 4), + 'U' => CodePoint(next, txt, ref num, 8), + var _ => throw new Exception("Undefined escape sequence!") + }); + i = num + 2; + } + + return stringBuilder.ToString(); + } + } + + #endregion +} \ No newline at end of file diff --git a/UnityMcpBridge/Editor/External/Tommy.cs.meta b/UnityMcpBridge/Editor/External/Tommy.cs.meta new file mode 100644 index 000000000..efcb8ff81 --- /dev/null +++ b/UnityMcpBridge/Editor/External/Tommy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ea652131dcdaa44ca8cb35cd1191be3f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs index 8bd217adc..fceab4795 100644 --- a/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs +++ b/UnityMcpBridge/Editor/Helpers/CodexConfigHelper.cs @@ -1,8 +1,10 @@ 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 @@ -60,8 +62,7 @@ public static string UpsertCodexServerBlock(string existingToml, string newBlock bool replaced = false; while ((line = reader.ReadLine()) != null) { - string sanitizedLine = StripTomlComment(line); - string trimmed = sanitizedLine.Trim(); + string trimmed = line.Trim(); bool isSection = trimmed.StartsWith("[") && trimmed.EndsWith("]") && !trimmed.StartsWith("[["); if (isSection) { @@ -105,141 +106,110 @@ public static bool TryParseCodexServer(string toml, out string command, out stri { command = null; args = null; - if (string.IsNullOrEmpty(toml)) return false; + if (string.IsNullOrWhiteSpace(toml)) return false; - using StringReader reader = new StringReader(toml); - string line; - bool inTarget = false; - while ((line = reader.ReadLine()) != null) + try { - string trimmed = line.Trim(); - if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("#")) continue; + using var reader = new StringReader(toml); + TomlTable root = TOML.Parse(reader); + if (root == null) return false; - string headerCandidate = StripTomlComment(trimmed).Trim(); - bool isSection = headerCandidate.StartsWith("[") && headerCandidate.EndsWith("]") && !headerCandidate.StartsWith("[["); - if (isSection) + if (!TryGetTable(root, "mcp_servers", out var servers) + && !TryGetTable(root, "mcpServers", out servers)) { - inTarget = string.Equals(headerCandidate, "[mcp_servers.unityMCP]", StringComparison.OrdinalIgnoreCase); - continue; + return false; } - if (!inTarget) continue; - - if (trimmed.StartsWith("command", StringComparison.OrdinalIgnoreCase)) + if (!TryGetTable(servers, "unityMCP", out var unity)) { - int eq = trimmed.IndexOf('='); - if (eq >= 0) - { - string raw = trimmed[(eq + 1)..]; - command = ParseTomlStringValue(raw); - } - } - else if (trimmed.StartsWith("args", StringComparison.OrdinalIgnoreCase)) - { - int eq = trimmed.IndexOf('='); - if (eq >= 0) - { - string raw = trimmed[(eq + 1)..].Trim(); - string aggregated = CollectTomlArray(raw, reader); - args = ParseTomlStringArray(aggregated); - } + return false; } - } - return !string.IsNullOrEmpty(command) && args != null; - } + command = GetTomlString(unity, "command"); + args = GetTomlStringArray(unity, "args"); - private static string CollectTomlArray(string firstSegment, StringReader reader) - { - StringBuilder buffer = new StringBuilder(); - string sanitizedFirst = StripTomlComment(firstSegment ?? string.Empty).Trim(); - buffer.Append(sanitizedFirst); - - if (IsTomlArrayComplete(buffer.ToString())) + return !string.IsNullOrEmpty(command) && args != null; + } + catch (TomlParseException) { - return buffer.ToString(); + return false; } - - string nextLine; - while ((nextLine = reader.ReadLine()) != null) + catch (TomlSyntaxException) { - string sanitizedNext = StripTomlComment(nextLine).Trim(); - buffer.AppendLine(); - buffer.Append(sanitizedNext); - - if (IsTomlArrayComplete(buffer.ToString())) - { - break; - } + return false; + } + catch (FormatException) + { + return false; } - - return buffer.ToString(); } - private static bool IsTomlArrayComplete(string text) + private static bool TryGetTable(TomlTable parent, string key, out TomlTable table) { - if (string.IsNullOrWhiteSpace(text)) return false; + table = null; + if (parent == null) return false; - bool inDouble = false; - bool inSingle = false; - bool escape = false; - int depth = 0; - bool sawOpen = false; - - foreach (char c in text) + if (parent.TryGetNode(key, out var node)) { - if (escape) + if (node is TomlTable tbl) { - escape = false; - continue; + table = tbl; + return true; } - if (c == '\\') + if (node is TomlArray array) { - if (inDouble) + var firstTable = array.Children.OfType().FirstOrDefault(); + if (firstTable != null) { - escape = true; + table = firstTable; + return true; } - continue; } + } - if (c == '"' && !inSingle) - { - inDouble = !inDouble; - continue; - } + return false; + } - if (c == '\'' && !inDouble) - { - inSingle = !inSingle; - continue; - } + 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; + } - if (inDouble || inSingle) - { - continue; - } + private static string[] GetTomlStringArray(TomlTable table, string key) + { + if (table == null) return null; + if (!table.TryGetNode(key, out var node)) return null; - if (c == '[') - { - depth++; - sawOpen = true; - } - else if (c == ']') + if (node is TomlArray array) + { + List values = new List(); + foreach (TomlNode element in array.Children) { - if (depth > 0) + if (element is TomlString str) { - depth--; + values.Add(str.Value); + } + else if (element.HasValue) + { + values.Add(element.ToString()); } } - } - if (!sawOpen) return false; + return values.Count > 0 ? values.ToArray() : Array.Empty(); + } - if (depth > 0) return false; + if (node is TomlString single) + { + return new[] { single.Value }; + } - int closingIndex = text.LastIndexOf(']'); - return closingIndex >= 0; + return null; } private static string FormatTomlStringArray(IEnumerable values) @@ -269,111 +239,5 @@ private static string EscapeTomlString(string value) .Replace("\"", "\\\""); } - private static string ParseTomlStringValue(string value) - { - if (value == null) return null; - string trimmed = StripTomlComment(value).Trim(); - if (trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"') - { - return UnescapeTomlBasicString(trimmed.Substring(1, trimmed.Length - 2)); - } - if (trimmed.Length >= 2 && trimmed[0] == '\'' && trimmed[^1] == '\'') - { - return trimmed.Substring(1, trimmed.Length - 2); - } - return trimmed.Trim(); - } - - private static string[] ParseTomlStringArray(string value) - { - if (value == null) return null; - string cleaned = StripTomlComment(value).Trim(); - if (!cleaned.StartsWith("[") || !cleaned.EndsWith("]")) return null; - - cleaned = Regex.Replace(cleaned, @",(?=\s*\])", string.Empty); - - try - { - return JsonConvert.DeserializeObject(cleaned); - } - catch - { - if (cleaned.IndexOf('"') < 0 && cleaned.IndexOf('\'') >= 0) - { - string normalized = Regex.Replace( - cleaned, - @"'((?:[^']|'')*)'", - m => JsonConvert.SerializeObject(m.Groups[1].Value.Replace("''", "'")) - ); - try { return JsonConvert.DeserializeObject(normalized); } catch { } - } - } - return null; - } - - private static string StripTomlComment(string value) - { - if (string.IsNullOrEmpty(value)) return string.Empty; - bool inDouble = false; - bool inSingle = false; - bool escape = false; - for (int i = 0; i < value.Length; i++) - { - char c = value[i]; - if (escape) - { - escape = false; - continue; - } - if (c == '\\' && inDouble) - { - escape = true; - continue; - } - if (c == '"' && !inSingle) - { - inDouble = !inDouble; - continue; - } - if (c == '\'' && !inDouble) - { - inSingle = !inSingle; - continue; - } - if (c == '#' && !inSingle && !inDouble) - { - return value.Substring(0, i).TrimEnd(); - } - } - return value.Trim(); - } - - private static string UnescapeTomlBasicString(string value) - { - if (string.IsNullOrEmpty(value)) return string.Empty; - StringBuilder sb = new StringBuilder(value.Length); - for (int i = 0; i < value.Length; i++) - { - char c = value[i]; - if (c == '\\' && i + 1 < value.Length) - { - char next = value[++i]; - sb.Append(next switch - { - '\\' => '\\', - '"' => '"', - 'n' => '\n', - 'r' => '\r', - 't' => '\t', - 'b' => '\b', - 'f' => '\f', - _ => next - }); - continue; - } - sb.Append(c); - } - return sb.ToString(); - } } } From 15978359c9b919dfd217d117ab872170beb89171 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 16:21:57 -0400 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20patched=20Tommy=E2=80=99s=20litera?= =?UTF-8?q?l-string=20handling=20so=20doubled=20single=20quotes=20inside?= =?UTF-8?q?=20literal=20strings=20are=20treated=20as=20embedded=20apostrop?= =?UTF-8?q?hes=20instead=20of=20prematurely=20ending=20the=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UnityMcpBridge/Editor/External/Tommy.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/UnityMcpBridge/Editor/External/Tommy.cs b/UnityMcpBridge/Editor/External/Tommy.cs index 08172e7d7..e2fd4ce3a 100644 --- a/UnityMcpBridge/Editor/External/Tommy.cs +++ b/UnityMcpBridge/Editor/External/Tommy.cs @@ -1401,7 +1401,18 @@ private bool ProcessQuotedValueCharacter(char quote, return false; } - if (c == quote) return true; + if (c == quote) + { + if (!isNonLiteral && reader.Peek() == quote) + { + reader.Read(); + col++; + sb.Append(quote); + return false; + } + + return true; + } if (isNonLiteral && c == TomlSyntax.ESCAPE_SYMBOL) escaped = true; if (c == TomlSyntax.NEWLINE_CHARACTER) @@ -2096,4 +2107,4 @@ static string CodePoint(int next, string txt, ref int num, int size) } #endregion -} \ No newline at end of file +} From e5a853524310fbb9fa4808c533a60d505686ca91 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 16:30:47 -0400 Subject: [PATCH 09/12] Don't overwrite MCP configs while testing Seeing random JSON in my codex config was pretty annoying --- .../Assets/Tests/EditMode/Windows/WriteToConfigTests.cs | 3 +++ UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs index c8f13b0c5..3fd77088a 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Windows/WriteToConfigTests.cs @@ -46,6 +46,8 @@ public void SetUp() EditorPrefs.SetString("MCPForUnity.ServerSrc", _serverSrcDir); // Ensure no lock is enabled EditorPrefs.SetBool("MCPForUnity.LockCursorConfig", false); + // Disable auto-registration to avoid hitting user configs during tests + EditorPrefs.SetBool("MCPForUnity.AutoRegisterEnabled", false); } [TearDown] @@ -54,6 +56,7 @@ public void TearDown() // Clean up editor preferences set during SetUp EditorPrefs.DeleteKey("MCPForUnity.ServerSrc"); EditorPrefs.DeleteKey("MCPForUnity.LockCursorConfig"); + EditorPrefs.DeleteKey("MCPForUnity.AutoRegisterEnabled"); // Remove temp files try { if (Directory.Exists(_tempRoot)) Directory.Delete(_tempRoot, true); } catch { } diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index 8765856ac..b588ba355 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -1766,7 +1766,9 @@ private void CheckMcpConfiguration(McpClient mcpClient) // Attempt auto-rewrite once if the package path changed 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 (debugLogsEnabled) From eff04579b681a6955d245aa6087a17eb9651e09b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 16:53:10 -0400 Subject: [PATCH 10/12] PR Feedback --- .../Editor/Windows/MCPForUnityEditorWindow.cs | 64 ++----------------- 1 file changed, 4 insertions(+), 60 deletions(-) diff --git a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs index b588ba355..cdaa6c17e 100644 --- a/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/UnityMcpBridge/Editor/Windows/MCPForUnityEditorWindow.cs @@ -568,12 +568,8 @@ private void AutoFirstRunSetup() } else { - bool alreadyConfigured = client.mcpType switch - { - McpTypes.Codex => CodexConfigHelper.IsCodexConfigured(pythonDir), - _ => IsCursorConfigured(pythonDir) - }; - + CheckMcpConfiguration(client); + bool alreadyConfigured = client.status == McpStatus.Configured; if (!alreadyConfigured) { ConfigureMcpClient(client); @@ -666,12 +662,8 @@ private void RunSetupNow() } else { - bool alreadyConfigured = client.mcpType switch - { - McpTypes.Codex => CodexConfigHelper.IsCodexConfigured(pythonDir), - _ => IsCursorConfigured(pythonDir) - }; - + CheckMcpConfiguration(client); + bool alreadyConfigured = client.status == McpStatus.Configured; if (!alreadyConfigured) { ConfigureMcpClient(client); @@ -1149,54 +1141,6 @@ private static bool ArgsEqual(string[] a, string[] b) return true; } - private 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 { } - } - } - private string WriteToConfig(string pythonDir, string configPath, McpClient mcpClient = null) { // 0) Respect explicit lock (hidden pref or UI toggle) From 92bbe388f4424ff206a510a1398ca7423de9470c Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 16:55:34 -0400 Subject: [PATCH 11/12] Keep Tommy compatible with Unity 2021 --- UnityMcpBridge/Editor/External/Tommy.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/UnityMcpBridge/Editor/External/Tommy.cs b/UnityMcpBridge/Editor/External/Tommy.cs index e2fd4ce3a..c1c3e7881 100644 --- a/UnityMcpBridge/Editor/External/Tommy.cs +++ b/UnityMcpBridge/Editor/External/Tommy.cs @@ -347,7 +347,7 @@ public override void WriteTo(TextWriter tw, string name = null) return; } - if (Comment is not null) + if (!(Comment is null)) { tw.WriteLine(); Comment.AsComment(tw); @@ -363,7 +363,7 @@ public override void WriteTo(TextWriter tw, string name = null) foreach (var tomlNode in RawArray) { - if (tomlNode is not TomlTable tbl) + if (!(tomlNode is TomlTable tbl)) throw new TomlFormatException("The array is marked as array table but contains non-table nodes!"); // Ensure it's parsed as a section @@ -1676,7 +1676,7 @@ private TomlTable CreateTable(TomlNode root, IList path, bool arrayTable if (node.HasValue) { - if (node is not TomlArray { IsTableArray: true } array) + if (!(node is TomlArray { IsTableArray: true } array)) { AddError($"The key {".".Join(path)} has a value assigned to it!"); return null; From 68ef6cc046edce543ed69a00dcb5b1f56fce2561 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Fri, 26 Sep 2025 17:05:33 -0400 Subject: [PATCH 12/12] Re-include Tommy's license Probably a good habit to keep all 3rd party licenses and copyrights, even if they're also MIT licenses --- UnityMcpBridge/Editor/External/Tommy.cs | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/UnityMcpBridge/Editor/External/Tommy.cs b/UnityMcpBridge/Editor/External/Tommy.cs index c1c3e7881..95399e406 100644 --- a/UnityMcpBridge/Editor/External/Tommy.cs +++ b/UnityMcpBridge/Editor/External/Tommy.cs @@ -1,3 +1,31 @@ +#region LICENSE + +/* + * MIT License + * + * Copyright (c) 2020 Denis Zhidkikh + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#endregion + using System; using System.Collections; using System.Collections.Generic;