using System.IO; using System.Text; using System.Text.RegularExpressions; namespace io.github.hatayama.uLoopMCP { /// /// Configuration service for Codex ({projectRoot}/.codex/config.toml), TOML string-based. /// public sealed class CodexTomlConfigService : IMcpConfigService { private static readonly Regex SectionRegex = new Regex( @"(?ms)^\[mcp_servers\.uLoopMCP\]\s*.*?(?=^\[|\z)", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex AnyMcpServerRegex = new Regex( @"(?ms)^\[mcp_servers\.[^\]]+\]\s*.*?(?=^\[|\z)", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex Arg0Regex = new Regex( @"(?ms)^\[mcp_servers\.uLoopMCP\].*?^\s*args\s*=\s*\[\s*(['""])(?[^'""]+)\1", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex PortRegex = new Regex( @"env\s*=\s*\{[^}]*""UNITY_TCP_PORT""\s*=\s*['""]?(?\d+)['""]?", RegexOptions.Compiled | RegexOptions.CultureInvariant); // Inner-scope regexes for parsing within the matched uLoopMCP section private static readonly Regex Arg0InnerRegex = new Regex( @"(?m)^\s*args\s*=\s*\[\s*(['""])(?[^'""]+)\1", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex PortInnerRegex = new Regex( @"(?m)^\s*env\s*=\s*\{[^\}]*""UNITY_TCP_PORT""\s*=\s*['""]?(?\d+)['""]?", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex EnvLineRegex = new Regex( @"(?m)^\s*env\s*=\s*\{(?[^\}]*)\}", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex EnvPairRegex = new Regex( @"(?m)""(?[^""]+)""\s*=\s*(?:""(?[^""]+)""|(?[^,}\r\n]+))", RegexOptions.Compiled | RegexOptions.CultureInvariant); public bool IsConfigured() { string path = UnityMcpPathResolver.GetCodexConfigPath(); if (!File.Exists(path)) return false; string content = File.ReadAllText(path); return SectionRegex.IsMatch(content); } public bool IsUpdateNeeded(int port) { string path = UnityMcpPathResolver.GetCodexConfigPath(); if (!File.Exists(path)) return true; string content = File.ReadAllText(path); string serverAbsolutePath = UnityMcpPathResolver.GetTypeScriptServerPath(); if (string.IsNullOrEmpty(serverAbsolutePath) || !File.Exists(serverAbsolutePath)) return false; string expectedArg0 = UnityMcpPathResolver.MakeRelativeToConfigurationRoot(serverAbsolutePath); (string arg0, int? existingPort) = ReadCurrentValues(content); if (string.IsNullOrEmpty(arg0) || existingPort == null) return true; string normalizedArg0 = NormalizeForCompare(arg0); string normalizedExpected = NormalizeForCompare(expectedArg0); if (!string.Equals(normalizedArg0, normalizedExpected, System.StringComparison.OrdinalIgnoreCase) || existingPort.Value != port) return true; // When compiled with ULOOPMCP_DEBUG, require debug env flags to be present #if ULOOPMCP_DEBUG Match section = SectionRegex.Match(content); if (section.Success) { System.Collections.Generic.Dictionary envPairs = ParseEnvPairs(section.Value); bool hasMcpDebug = envPairs.TryGetValue("MCP_DEBUG", out string debugVal) && string.Equals(debugVal, "true", System.StringComparison.Ordinal); bool hasNodeOptions = envPairs.TryGetValue("NODE_OPTIONS", out string nodeOpt) && string.Equals(nodeOpt, "--enable-source-maps", System.StringComparison.Ordinal); if (!hasMcpDebug || !hasNodeOptions) return true; } #endif return false; } public void AutoConfigure(int port) { string path = UnityMcpPathResolver.GetCodexConfigPath(); EnsureDirectory(path); string content = File.Exists(path) ? File.ReadAllText(path) : string.Empty; string serverPath = UnityMcpPathResolver.GetTypeScriptServerPath(); if (string.IsNullOrEmpty(serverPath) || !File.Exists(serverPath)) { throw new System.InvalidOperationException("TypeScript server bundle path not found."); } // Use relative path for better portability (config is now project-level) string relativeServerPath = UnityMcpPathResolver.MakeRelativeToConfigurationRoot(serverPath); string block = BuildBlock(port, relativeServerPath); string result; if (SectionRegex.IsMatch(content)) { result = SectionRegex.Replace(content, block.TrimEnd() + System.Environment.NewLine); } else { var matches = AnyMcpServerRegex.Matches(content); if (matches.Count > 0) { var last = matches[matches.Count - 1]; int insertIndex = last.Index + last.Length; result = content.Insert(insertIndex, System.Environment.NewLine + block); } else { result = string.IsNullOrWhiteSpace(content) ? block : content.TrimEnd() + System.Environment.NewLine + System.Environment.NewLine + block; } } File.WriteAllText(path, result); } public int GetConfiguredPort() { string path = UnityMcpPathResolver.GetCodexConfigPath(); if (!File.Exists(path)) throw new System.InvalidOperationException("Configuration file not found."); string content = File.ReadAllText(path); (_, int? port) = ReadCurrentValues(content); if (port == null) throw new System.InvalidOperationException("UNITY_TCP_PORT not found."); return port.Value; } public void DeleteConfiguration() { string path = UnityMcpPathResolver.GetCodexConfigPath(); if (!File.Exists(path)) { return; } string content = File.ReadAllText(path); string result = SectionRegex.Replace(content, string.Empty); if (ReferenceEquals(content, result)) { return; } result = Regex.Replace(result, @"(\r?\n){3,}", System.Environment.NewLine + System.Environment.NewLine); File.WriteAllText(path, result); } public void UpdateDevelopmentSettings(int port, bool developmentMode, bool enableMcpLogs) { string path = UnityMcpPathResolver.GetCodexConfigPath(); if (!File.Exists(path)) { AutoConfigure(port); } string content = File.ReadAllText(path); // If section not exists, create it first and reload if (!SectionRegex.IsMatch(content)) { AutoConfigure(port); content = File.ReadAllText(path); } // Replace only within the uLoopMCP section and update dev logs settings Match section = SectionRegex.Match(content); if (!section.Success) { // As a last attempt, try to autoconfigure and reload once AutoConfigure(port); content = File.ReadAllText(path); section = SectionRegex.Match(content); if (!section.Success) { return; } } string updatedSection = UpdateSectionWithDevelopmentSettings(section.Value, port, developmentMode, enableMcpLogs); string newContent = content.Substring(0, section.Index) + updatedSection + content.Substring(section.Index + section.Length); File.WriteAllText(path, newContent); } private static (string arg0, int? port) ReadCurrentValues(string content) { Match section = SectionRegex.Match(content); if (!section.Success) return (null, null); string arg0 = null; int? port = null; Match a = Arg0InnerRegex.Match(section.Value); if (a.Success) { arg0 = a.Groups["arg0"].Value; } Match p = PortInnerRegex.Match(section.Value); if (p.Success) { int value; if (int.TryParse(p.Groups["port"].Value, out value)) { port = value; } } return (arg0, port); } private static string BuildBlock(int port, string serverPath) { // Use single-quoted TOML literal string for args so backslashes don't need escaping string literalPath = serverPath.Replace("'", "''"); StringBuilder sb = new StringBuilder(); sb.AppendLine("[mcp_servers.uLoopMCP]"); sb.AppendLine("command = \"node\""); sb.Append("args = ['").Append(literalPath).AppendLine("']"); System.Text.StringBuilder envLine = new System.Text.StringBuilder(); envLine.Append("env = { \"UNITY_TCP_PORT\" = \"").Append(port.ToString()).Append("\""); #if ULOOPMCP_DEBUG envLine.Append(", \"MCP_DEBUG\" = \"true\", \"NODE_OPTIONS\" = \"--enable-source-maps\""); #endif envLine.AppendLine(" }"); sb.Append(envLine.ToString()); return sb.ToString(); } private static string NormalizeForCompare(string path) { if (string.IsNullOrEmpty(path)) return path; // Normalize to forward slashes for comparison (single-quoted TOML writes raw backslashes) return path.Replace('\\', '/'); } private static string UpdateSectionWithDevelopmentSettings(string sectionText, int port, bool developmentMode, bool enableMcpLogs) { // Ensure env line exists and update key-values Match envMatch = EnvLineRegex.Match(sectionText); string envBody = envMatch.Success ? envMatch.Groups["body"].Value : string.Empty; System.Collections.Generic.Dictionary pairs = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); if (!string.IsNullOrEmpty(envBody)) { MatchCollection mc = EnvPairRegex.Matches(envBody); foreach (Match m in mc) { string key = m.Groups["key"].Value; string value = m.Groups["value"].Value; if (!pairs.ContainsKey(key)) { pairs.Add(key, value); } } } // Update mandatory port pairs["UNITY_TCP_PORT"] = port.ToString(); // Clean legacy flags pairs.Remove("ULOOPMCP_DEBUG"); pairs.Remove("ULOOPMCP_PRODUCTION"); // Apply MCP debug logs if (enableMcpLogs) { pairs["MCP_DEBUG"] = "true"; pairs["NODE_OPTIONS"] = "--enable-source-maps"; } else { pairs.Remove("MCP_DEBUG"); pairs.Remove("NODE_OPTIONS"); } // Rebuild env line with deterministic ordering System.Collections.Generic.List orderedKeys = new System.Collections.Generic.List(); if (pairs.ContainsKey("UNITY_TCP_PORT")) orderedKeys.Add("UNITY_TCP_PORT"); if (pairs.ContainsKey("MCP_DEBUG")) orderedKeys.Add("MCP_DEBUG"); if (pairs.ContainsKey("NODE_OPTIONS")) orderedKeys.Add("NODE_OPTIONS"); foreach (System.Collections.Generic.KeyValuePair kv in pairs) { if (orderedKeys.Contains(kv.Key)) continue; orderedKeys.Add(kv.Key); } System.Text.StringBuilder envBuilder = new System.Text.StringBuilder(); for (int i = 0; i < orderedKeys.Count; i++) { string key = orderedKeys[i]; string value = pairs[key]; if (i > 0) envBuilder.Append(", "); envBuilder.Append("\"").Append(key).Append("\"").Append(" = "); envBuilder.Append("\"").Append(value).Append("\""); } string newEnvLine = "env = { " + envBuilder.ToString() + " }"; string updatedSection; if (envMatch.Success) { updatedSection = sectionText.Substring(0, envMatch.Index) + newEnvLine + sectionText.Substring(envMatch.Index + envMatch.Length); } else { // Insert after args line if exists, otherwise append at end Match argsMatch = Arg0InnerRegex.Match(sectionText); if (argsMatch.Success) { int insertIndex = argsMatch.Index + argsMatch.Length; // Find end of line int lineEnd = sectionText.IndexOf('\n', insertIndex); if (lineEnd < 0) lineEnd = insertIndex; string prefix = sectionText.Substring(0, lineEnd + 1); string suffix = sectionText.Substring(lineEnd + 1); updatedSection = prefix + newEnvLine + System.Environment.NewLine + suffix; } else { updatedSection = sectionText.TrimEnd() + System.Environment.NewLine + newEnvLine + System.Environment.NewLine; } } return updatedSection; } private static System.Collections.Generic.Dictionary ParseEnvPairs(string sectionText) { System.Collections.Generic.Dictionary pairs = new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal); Match envMatch = EnvLineRegex.Match(sectionText); if (!envMatch.Success) return pairs; string envBody = envMatch.Groups["body"].Value; if (string.IsNullOrEmpty(envBody)) return pairs; MatchCollection mc = EnvPairRegex.Matches(envBody); foreach (Match m in mc) { string key = m.Groups["key"].Value; string value = m.Groups["value"].Value; if (!pairs.ContainsKey(key)) { pairs.Add(key, value); } } return pairs; } private static void EnsureDirectory(string filePath) { string dir = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); } } }