Files
ft/Client/Packages/io.github.hatayama.uloopmcp/Editor/Config/CodexTomlConfigService.cs

358 lines
15 KiB
C#

using System.IO;
using System.Text;
using System.Text.RegularExpressions;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Configuration service for Codex ({projectRoot}/.codex/config.toml), TOML string-based.
/// </summary>
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*(['""])(?<arg0>[^'""]+)\1",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex PortRegex = new Regex(
@"env\s*=\s*\{[^}]*""UNITY_TCP_PORT""\s*=\s*['""]?(?<port>\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*(['""])(?<arg0>[^'""]+)\1",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex PortInnerRegex = new Regex(
@"(?m)^\s*env\s*=\s*\{[^\}]*""UNITY_TCP_PORT""\s*=\s*['""]?(?<port>\d+)['""]?",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex EnvLineRegex = new Regex(
@"(?m)^\s*env\s*=\s*\{(?<body>[^\}]*)\}",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex EnvPairRegex = new Regex(
@"(?m)""(?<key>[^""]+)""\s*=\s*(?:""(?<value>[^""]+)""|(?<value>[^,}\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<string, string> 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<string, string> pairs = new System.Collections.Generic.Dictionary<string, string>(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<string> orderedKeys = new System.Collections.Generic.List<string>();
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<string, string> 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<string, string> ParseEnvPairs(string sectionText)
{
System.Collections.Generic.Dictionary<string, string> pairs = new System.Collections.Generic.Dictionary<string, string>(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);
}
}
}