358 lines
15 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
|