/* ┌──────────────────────────────────────────────────────────────────┐ │ Author: Ivan Murzak (https://github.com/IvanMurzak) │ │ Repository: GitHub (https://github.com/IvanMurzak/Unity-MCP) │ │ Copyright (c) 2025 Ivan Murzak │ │ Licensed under the Apache License, Version 2.0. │ │ See the LICENSE file in the project root for more information. │ └──────────────────────────────────────────────────────────────────┘ */ #nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using com.IvanMurzak.McpPlugin.Common; using UnityEngine; using static com.IvanMurzak.McpPlugin.Common.Consts.MCP.Server; namespace com.IvanMurzak.Unity.MCP.Editor.Utils { public class TomlAiAgentConfig : AiAgentConfig { private readonly Dictionary _properties = new(); private readonly HashSet _propertiesToRemove = new(); /// /// Wraps a raw TOML value string for types not explicitly supported by the parser /// (e.g., floats, dates). The value is written back verbatim during serialization. /// private sealed class RawTomlValue { public string Value { get; } public RawTomlValue(string value) => Value = value; } public override string ExpectedFileContent { get { var sectionName = $"{BodyPath}.{DefaultMcpServerName}"; var sb = new StringBuilder(); sb.AppendLine($"[{sectionName}]"); foreach (var key in _properties.Keys.OrderBy(k => k, StringComparer.Ordinal)) { sb.AppendLine(FormatTomlProperty(key, _properties[key].value)); } return sb.ToString(); } } public TomlAiAgentConfig( string name, string configPath, string bodyPath = Consts.MCP.Server.DefaultBodyPath) : base( name: name, configPath: configPath, bodyPath: bodyPath) { // empty } public TomlAiAgentConfig SetProperty(string key, object value, bool requiredForConfiguration = false, ValueComparisonMode comparison = ValueComparisonMode.Exact) { _properties[key] = (value, requiredForConfiguration, comparison); return this; } public TomlAiAgentConfig SetProperty(string key, object[] values, bool requiredForConfiguration = false, ValueComparisonMode comparison = ValueComparisonMode.Exact) { _properties[key] = (values, requiredForConfiguration, comparison); return this; } public TomlAiAgentConfig SetPropertyToRemove(string key) { _propertiesToRemove.Add(key); return this; } public new TomlAiAgentConfig AddIdentityKey(string key) { base.AddIdentityKey(key); return this; } public override void ApplyHttpAuthorization(bool isRequired, string? token) { // TOML HTTP config format does not currently support injecting // authorization headers. Implement when TOML HTTP auth is needed. } public override void ApplyStdioAuthorization(bool isRequired, string? token) { if (!_properties.TryGetValue("args", out var argsProp) || argsProp.value is not string[] currentArgs) return; var tokenPrefix = $"{Args.Token}="; var filtered = currentArgs .Where(arg => !arg.StartsWith(tokenPrefix, StringComparison.Ordinal)) .ToList(); if (isRequired && !string.IsNullOrEmpty(token)) filtered.Add($"{tokenPrefix}{token}"); SetProperty("args", filtered.ToArray(), argsProp.required, argsProp.comparison); } public override bool Configure() { if (string.IsNullOrEmpty(ConfigPath)) return false; Debug.Log($"{Consts.Log.Tag} Configuring MCP client TOML with path: {ConfigPath} and bodyPath: {BodyPath}"); try { var sectionName = $"{BodyPath}.{DefaultMcpServerName}"; if (!File.Exists(ConfigPath)) { // Create all necessary directories var directory = Path.GetDirectoryName(ConfigPath); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); File.WriteAllText(ConfigPath, ExpectedFileContent); return true; } // Read existing TOML file var lines = File.ReadAllLines(ConfigPath).ToList(); // Remove deprecated sections foreach (var deprecatedName in DeprecatedMcpServerNames) { var deprecatedSection = $"{BodyPath}.{deprecatedName}"; var deprecatedIndex = FindTomlSection(lines, deprecatedSection); if (deprecatedIndex >= 0) { var deprecatedEnd = FindSectionEnd(lines, deprecatedIndex); lines.RemoveRange(deprecatedIndex, deprecatedEnd - deprecatedIndex); } } // Remove duplicate sections that represent the same server under a different name RemoveDuplicateServerSections(lines, sectionName); var sectionIndex = FindTomlSection(lines, sectionName); if (sectionIndex >= 0) { // Section exists - merge properties var sectionEndIndex = FindSectionEnd(lines, sectionIndex); var existingProps = ParseSectionProperties(lines, sectionIndex + 1, sectionEndIndex); // Remove specified properties foreach (var key in _propertiesToRemove) existingProps.Remove(key); // Set/overwrite properties from _properties foreach (var prop in _properties) existingProps[prop.Key] = prop.Value.value; // Remove old section lines lines.RemoveRange(sectionIndex, sectionEndIndex - sectionIndex); // Generate new section from merged properties var newSection = GenerateTomlSectionFromDict(sectionName, existingProps); lines.Insert(sectionIndex, newSection.TrimEnd()); } else { // Section doesn't exist - append if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(""); var propsDict = _properties.ToDictionary(p => p.Key, p => p.Value.value); lines.Add(GenerateTomlSectionFromDict(sectionName, propsDict).TrimEnd()); } // Write back to file File.WriteAllText(ConfigPath, string.Join(Environment.NewLine, lines)); return IsConfigured(); } catch (Exception ex) { Debug.LogError($"{Consts.Log.Tag} Error configuring TOML file: {ex.Message}"); Debug.LogException(ex); return false; } } public override bool Unconfigure() { if (string.IsNullOrEmpty(ConfigPath) || !File.Exists(ConfigPath)) return false; try { var sectionName = $"{BodyPath}.{DefaultMcpServerName}"; var lines = File.ReadAllLines(ConfigPath).ToList(); var changed = false; var sectionIndex = FindTomlSection(lines, sectionName); if (sectionIndex >= 0) { var sectionEnd = FindSectionEnd(lines, sectionIndex); lines.RemoveRange(sectionIndex, sectionEnd - sectionIndex); changed = true; } foreach (var deprecatedName in DeprecatedMcpServerNames) { var deprecatedSection = $"{BodyPath}.{deprecatedName}"; var deprecatedIndex = FindTomlSection(lines, deprecatedSection); if (deprecatedIndex >= 0) { var deprecatedEnd = FindSectionEnd(lines, deprecatedIndex); lines.RemoveRange(deprecatedIndex, deprecatedEnd - deprecatedIndex); changed = true; } } var countBefore = lines.Count; RemoveDuplicateServerSections(lines, sectionName); if (lines.Count != countBefore) changed = true; if (!changed) return false; File.WriteAllText(ConfigPath, string.Join(Environment.NewLine, lines)); return true; } catch (Exception ex) { Debug.LogError($"{Consts.Log.Tag} Error unconfiguring TOML MCP client: {ex.Message}"); Debug.LogException(ex); return false; } } public override bool IsDetected() { if (string.IsNullOrEmpty(ConfigPath) || !File.Exists(ConfigPath)) return false; try { var lines = File.ReadAllLines(ConfigPath).ToList(); var sectionName = $"{BodyPath}.{DefaultMcpServerName}"; if (FindTomlSection(lines, sectionName) >= 0) return true; foreach (var deprecatedName in DeprecatedMcpServerNames) if (FindTomlSection(lines, $"{BodyPath}.{deprecatedName}") >= 0) return true; return FindDuplicateServerSectionIndices(lines, sectionName).Count > 0; } catch (Exception ex) { Debug.LogError($"{Consts.Log.Tag} Error reading TOML config file: {ex.Message}"); Debug.LogException(ex); return false; } } public override bool IsConfigured() { if (string.IsNullOrEmpty(ConfigPath) || !File.Exists(ConfigPath)) return false; try { var lines = File.ReadAllLines(ConfigPath).ToList(); var sectionName = $"{BodyPath}.{DefaultMcpServerName}"; var sectionIndex = FindTomlSection(lines, sectionName); if (sectionIndex < 0) return false; var sectionEndIndex = FindSectionEnd(lines, sectionIndex); var existingProps = ParseSectionProperties(lines, sectionIndex + 1, sectionEndIndex); return AreRequiredPropertiesMatching(existingProps) && !HasPropertiesToRemove(existingProps); } catch (Exception ex) { Debug.LogError($"{Consts.Log.Tag} Error reading TOML config file: {ex.Message}"); Debug.LogException(ex); return false; } } private bool AreRequiredPropertiesMatching(Dictionary existingProps) { foreach (var prop in _properties) { if (!prop.Value.required) continue; if (!existingProps.TryGetValue(prop.Key, out var existingValue)) return false; if (!ValuesMatch(prop.Value.comparison, prop.Value.value, existingValue)) return false; } return true; } private bool HasPropertiesToRemove(Dictionary existingProps) { if (_propertiesToRemove.Count == 0) return false; return _propertiesToRemove.Any(key => existingProps.ContainsKey(key)); } private static bool ValuesMatch(ValueComparisonMode comparison, object expected, object actual) { return (expected, actual) switch { (string e, string a) => AreStringValuesEquivalent(comparison, e, a), (string[] e, string[] a) => e.Length == a.Length && e.Zip(a, (x, y) => AreStringValuesEquivalent(comparison, x, y)).All(match => match), (bool e, bool a) => e == a, (bool[] e, bool[] a) => e.Length == a.Length && e.Zip(a, (x, y) => x == y).All(match => match), (int e, int a) => e == a, (int[] e, int[] a) => e.Length == a.Length && e.Zip(a, (x, y) => x == y).All(match => match), (Dictionary e, Dictionary a) => e.Count == a.Count && e.All(kv => a.TryGetValue(kv.Key, out var v) && AreStringValuesEquivalent(comparison, kv.Value, v)), _ => false }; } private static bool AreStringValuesEquivalent(ValueComparisonMode comparison, string expected, string actual) { if (comparison == ValueComparisonMode.Path) return NormalizePath(expected) == NormalizePath(actual); if (comparison == ValueComparisonMode.Url) return string.Equals(NormalizeUrl(expected), NormalizeUrl(actual), StringComparison.OrdinalIgnoreCase); return expected == actual; } /// Normalizes a file path by unifying separators. private static string NormalizePath(string path) { return path.Replace('\\', '/').TrimEnd('/'); } /// Normalizes a URL by lowercasing scheme+host and trimming trailing slashes. private static string NormalizeUrl(string url) { if (Uri.TryCreate(url, UriKind.Absolute, out var uri)) { var authority = uri.GetLeftPart(UriPartial.Authority).ToLowerInvariant(); var pathPart = uri.AbsolutePath.TrimEnd('/'); return authority + pathPart + uri.Query; } return url.TrimEnd('/'); } private static string GenerateTomlSectionFromDict(string sectionName, Dictionary properties) { var sb = new StringBuilder(); sb.AppendLine($"[{sectionName}]"); foreach (var key in properties.Keys.OrderBy(k => k, StringComparer.Ordinal)) { sb.AppendLine(FormatTomlProperty(key, properties[key])); } return sb.ToString(); } private static string FormatTomlProperty(string key, object value) { return value switch { string s => $"{key} = \"{EscapeTomlString(s)}\"", string[] arr => $"{key} = [{string.Join(",", arr.Select(v => $"\"{EscapeTomlString(v)}\""))}]", int i => $"{key} = {i}", int[] arr => $"{key} = [{string.Join(",", arr)}]", bool b => $"{key} = {b.ToString().ToLower()}", bool[] arr => $"{key} = [{string.Join(",", arr.Select(v => v.ToString().ToLower()))}]", Dictionary dict => FormatTomlInlineTable(key, dict), RawTomlValue raw => $"{key} = {raw.Value}", _ => throw new InvalidOperationException($"Unsupported TOML value type: {value.GetType()}") }; } private static Dictionary ParseSectionProperties(List lines, int startIndex, int endIndex) { var props = new Dictionary(); for (int i = startIndex; i < endIndex; i++) { var line = lines[i].Trim(); if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#")) continue; var parts = line.Split('=', 2); if (parts.Length != 2) continue; var key = parts[0].Trim(); var rawValue = parts[1].Trim(); if (rawValue.StartsWith("[")) { // Array value - strip inline comment then parse with type detection var arrayValue = StripArrayInlineComment(rawValue); props[key] = ParseTypedTomlArrayValue(arrayValue); } else if (rawValue.StartsWith("\"")) { // Quoted string value var stringValue = ParseTomlStringValue(line); if (stringValue != null) props[key] = stringValue; } else if (rawValue.StartsWith("{")) { // Inline table value: { "KEY" = "value", ... } var dictValue = ParseTomlInlineTable(rawValue); if (dictValue != null) props[key] = dictValue; else props[key] = new RawTomlValue(rawValue); } else { // Non-string, non-array scalar - strip inline comment first var scalarValue = StripInlineComment(rawValue); if (scalarValue == "true" || scalarValue == "false") props[key] = scalarValue == "true"; else if (int.TryParse(scalarValue, out var intValue)) props[key] = intValue; else if (scalarValue.Length > 0) props[key] = new RawTomlValue(scalarValue); } } return props; } private static string FormatTomlInlineTable(string key, Dictionary dict) { var pairs = string.Join(", ", dict.Select(kv => "\"" + EscapeTomlString(kv.Key) + "\" = \"" + EscapeTomlString(kv.Value) + "\"")); return key + " = { " + pairs + " }"; } /// /// Parses a TOML inline table such as { "KEY" = "value", KEY2 = "v2" } /// into a of string pairs. /// Returns null if the input cannot be parsed. /// private static Dictionary? ParseTomlInlineTable(string rawValue) { var trimmed = rawValue.Trim(); var closingBrace = trimmed.LastIndexOf('}'); if (!trimmed.StartsWith("{") || closingBrace < 0) return null; var content = trimmed[1..closingBrace].Trim(); var result = new Dictionary(); if (string.IsNullOrEmpty(content)) return result; var pos = 0; while (pos < content.Length) { // Skip whitespace/commas while (pos < content.Length && (char.IsWhiteSpace(content[pos]) || content[pos] == ',')) pos++; if (pos >= content.Length) break; // Parse key (quoted or bare) string? entryKey; if (content[pos] == '"') entryKey = ReadQuotedString(content, ref pos); else { var start = pos; while (pos < content.Length && content[pos] != '=' && content[pos] != ',') pos++; entryKey = content[start..pos].Trim(); } if (entryKey == null) return null; // Skip whitespace and '=' while (pos < content.Length && char.IsWhiteSpace(content[pos])) pos++; if (pos >= content.Length || content[pos] != '=') return null; pos++; // consume '=' while (pos < content.Length && char.IsWhiteSpace(content[pos])) pos++; // Parse value (quoted or bare) string? entryValue; if (pos < content.Length && content[pos] == '"') entryValue = ReadQuotedString(content, ref pos); else { var start = pos; while (pos < content.Length && content[pos] != ',') pos++; entryValue = content[start..pos].Trim(); } if (entryValue == null) return null; result[entryKey] = entryValue; } return result; } /// /// Reads a double-quoted TOML string starting at and advances past the closing quote. /// Returns null if the string is malformed. /// private static string? ReadQuotedString(string s, ref int pos) { if (pos >= s.Length || s[pos] != '"') return null; pos++; // skip opening quote var sb = new StringBuilder(); while (pos < s.Length) { if (s[pos] == '\\' && pos + 1 < s.Length) { pos++; switch (s[pos]) { case 'b': sb.Append('\b'); pos++; break; case 't': sb.Append('\t'); pos++; break; case 'n': sb.Append('\n'); pos++; break; case 'f': sb.Append('\f'); pos++; break; case 'r': sb.Append('\r'); pos++; break; case '"': sb.Append('"'); pos++; break; case '\\': sb.Append('\\'); pos++; break; case 'u' when pos + 4 < s.Length: { var hex = s.Substring(pos + 1, 4); if (int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var cp)) sb.Append((char)cp); pos += 5; break; } case 'U' when pos + 8 < s.Length: { var hex = s.Substring(pos + 1, 8); if (int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var cp)) sb.Append(char.ConvertFromUtf32(cp)); pos += 9; break; } default: // Invalid escape — preserve as-is (best-effort) sb.Append('\\'); sb.Append(s[pos]); pos++; break; } } else if (s[pos] == '"') { pos++; // skip closing quote return sb.ToString(); } else { sb.Append(s[pos]); pos++; } } return null; // unclosed quote } private static string EscapeTomlString(string value) { return value.Replace("\\", "\\\\").Replace("\"", "\\\""); } private static string StripInlineComment(string value) { var idx = value.IndexOf('#'); return idx >= 0 ? value[..idx].TrimEnd() : value; } /// /// Strips an inline comment from a TOML array value while respecting quoted strings. /// For example, ["a", "b"] # comment becomes ["a", "b"]. /// private static string StripArrayInlineComment(string value) { var depth = 0; var inQuote = false; var escaped = false; for (int i = 0; i < value.Length; i++) { var ch = value[i]; if (escaped) { escaped = false; continue; } if (ch == '\\' && inQuote) { escaped = true; continue; } if (ch == '"') { inQuote = !inQuote; continue; } if (inQuote) continue; if (ch == '[') depth++; else if (ch == ']') { depth--; if (depth == 0) return value[..(i + 1)]; } } return value; } private static string? ParseTomlStringValue(string line) { // Parse: key = "value" or key = "value" # comment var parts = line.Split('=', 2); if (parts.Length != 2) return null; var value = parts[1].Trim(); if (!value.StartsWith("\"")) return value; // Scan for closing quote, handling escape sequences for (int i = 1; i < value.Length; i++) { if (value[i] == '\\') { i++; // Skip escaped character continue; } if (value[i] == '"') { // Found closing quote - extract and unescape var inner = value[1..i]; return inner.Replace("\\\"", "\"").Replace("\\\\", "\\"); } } // No closing quote found - return raw value as fallback return value; } private static object ParseTypedTomlArrayValue(string rawValue) { // Remove array brackets if (!rawValue.StartsWith("[") || !rawValue.EndsWith("]")) return Array.Empty(); var arrayContent = rawValue[1..^1].Trim(); if (string.IsNullOrEmpty(arrayContent)) return Array.Empty(); // Detect array element type by looking at the first element. // Typed parsers return null when any element is invalid; // fall back to RawTomlValue to preserve the original text. if (arrayContent.StartsWith("\"")) { // String array return ParseTomlStringArrayContent(arrayContent); } else if (arrayContent.StartsWith("true", StringComparison.Ordinal) || arrayContent.StartsWith("false", StringComparison.Ordinal)) { // Boolean array return ParseTomlBoolArrayContent(arrayContent) ?? (object)new RawTomlValue(rawValue); } else if (char.IsDigit(arrayContent[0]) || arrayContent[0] == '-') { // Integer array return ParseTomlIntArrayContent(arrayContent) ?? (object)new RawTomlValue(rawValue); } // Fallback to string array return ParseTomlStringArrayContent(arrayContent); } private static string[] ParseTomlStringArrayContent(string arrayContent) { var result = new List(); var inQuote = false; var escaped = false; var currentValue = new StringBuilder(); foreach (var ch in arrayContent) { if (escaped) { currentValue.Append(ch); escaped = false; continue; } if (ch == '\\') { escaped = true; continue; } if (ch == '"') { if (inQuote) { // End of quoted string var parsedValue = currentValue.ToString(); parsedValue = parsedValue.Replace("\\\"", "\"").Replace("\\\\", "\\"); result.Add(parsedValue); currentValue.Clear(); } inQuote = !inQuote; } else if (inQuote) { currentValue.Append(ch); } } return result.ToArray(); } /// /// Parses a comma-separated list of boolean literals. /// Returns null if any element is not a valid boolean, so the caller /// can fall back to preserving the raw value. /// private static bool[]? ParseTomlBoolArrayContent(string arrayContent) { var elements = arrayContent.Split(','); var result = new bool[elements.Length]; for (int i = 0; i < elements.Length; i++) { var trimmed = elements[i].Trim().ToLowerInvariant(); if (trimmed == "true") result[i] = true; else if (trimmed == "false") result[i] = false; else return null; } return result; } /// /// Parses a comma-separated list of integer literals. /// Returns null if any element is not a valid integer, so the caller /// can fall back to preserving the raw value. /// private static int[]? ParseTomlIntArrayContent(string arrayContent) { var elements = arrayContent.Split(','); var result = new int[elements.Length]; for (int i = 0; i < elements.Length; i++) { if (!int.TryParse(elements[i].Trim(), out result[i])) return null; } return result; } /// /// Returns the (start, end) line index pairs of sibling TOML sections that represent the same /// server under a different name, identified by matching identity key property values /// (e.g. "command", "url", "serverUrl"). Pairs are in document order (top to bottom). /// private List<(int start, int end)> FindDuplicateServerSectionIndices(List lines, string ownSectionName) { var ourIdentityValues = _identityKeys .Where(key => _properties.TryGetValue(key, out var prop) && prop.value is string) .ToDictionary(key => key, key => ((string)_properties[key].value, _properties[key].comparison)); if (ourIdentityValues.Count == 0) return new List<(int, int)>(); var bodyPrefix = $"[{BodyPath}."; var result = new List<(int start, int end)>(); for (int i = 0; i < lines.Count; i++) { var trimmed = lines[i].Trim(); if (!trimmed.StartsWith(bodyPrefix) || !trimmed.EndsWith("]")) continue; var fullSectionName = trimmed[1..^1]; if (fullSectionName == ownSectionName) continue; var sectionEnd = FindSectionEnd(lines, i); var props = ParseSectionProperties(lines, i + 1, sectionEnd); if (ourIdentityValues.Any(identity => props.TryGetValue(identity.Key, out var existingValue) && existingValue is string existingStr && AreStringValuesEquivalent(identity.Value.comparison, identity.Value.Item1, existingStr))) result.Add((i, sectionEnd)); } return result; } /// /// Removes sibling TOML sections that represent the same server under a different name, /// identified by matching identity key property values (e.g. "command", "url", "serverUrl"). /// private void RemoveDuplicateServerSections(List lines, string ownSectionName) { var sectionsToRemove = FindDuplicateServerSectionIndices(lines, ownSectionName); for (int i = sectionsToRemove.Count - 1; i >= 0; i--) { var (start, end) = sectionsToRemove[i]; lines.RemoveRange(start, end - start); } } private static int FindTomlSection(List lines, string sectionName) { var sectionHeader = $"[{sectionName}]"; for (int i = 0; i < lines.Count; i++) { if (lines[i].Trim() == sectionHeader) return i; } return -1; } private static int FindSectionEnd(List lines, int sectionStartIndex) { // Find the next section or end of file for (int i = sectionStartIndex + 1; i < lines.Count; i++) { var trimmed = lines[i].Trim(); // New section starts with [ if (trimmed.StartsWith("[") && trimmed.EndsWith("]")) return i; } return lines.Count; } } }