using System.Collections.Generic; using System.IO; using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace io.github.hatayama.uLoopMCP { /// /// Class responsible for persisting MCP settings. /// Single Responsibility Principle: Only responsible for reading and writing configuration files. /// public class McpConfigRepository { // Security: Safe JSON serializer settings private static readonly JsonSerializerSettings SafeJsonSettings = new() { TypeNameHandling = TypeNameHandling.None, // Disable type information DateParseHandling = DateParseHandling.None, FloatParseHandling = FloatParseHandling.Double }; /// /// Checks if the configuration file exists. /// public bool Exists(string configPath) { return File.Exists(configPath); } /// /// Creates the configuration directory. /// public void CreateConfigDirectory(string configPath) { string configDir = Path.GetDirectoryName(configPath); if (string.IsNullOrEmpty(configDir) || Directory.Exists(configDir)) { return; } Directory.CreateDirectory(configDir); } /// /// Loads the mcp.json settings. /// public McpConfig Load(string configPath) { if (!File.Exists(configPath)) { return new McpConfig(new Dictionary()); } string jsonContent = File.ReadAllText(configPath); // Security: Validate JSON content before deserialization if (string.IsNullOrWhiteSpace(jsonContent) || jsonContent.Length > McpConstants.MAX_JSON_SIZE_BYTES) { return new McpConfig(new Dictionary()); } // First, load the existing JSON as a dictionary with safe settings. Dictionary rootObject = JsonConvert.DeserializeObject>(jsonContent, SafeJsonSettings); Dictionary servers = new(); // Check if the mcpServers section exists. if (rootObject == null || !rootObject.ContainsKey(McpConstants.JSON_KEY_MCP_SERVERS)) { return new McpConfig(servers); } // Get mcpServers as a dictionary with safe settings. string mcpServersJson = JsonConvert.SerializeObject(rootObject[McpConstants.JSON_KEY_MCP_SERVERS], SafeJsonSettings); Dictionary mcpServersObject = JsonConvert.DeserializeObject>(mcpServersJson, SafeJsonSettings); if (mcpServersObject == null) { return new McpConfig(servers); } foreach (KeyValuePair serverEntry in mcpServersObject) { string serverName = serverEntry.Key; // Get each server's settings as a dictionary with safe settings. string serverConfigJson = JsonConvert.SerializeObject(serverEntry.Value, SafeJsonSettings); Dictionary serverConfigObject = JsonConvert.DeserializeObject>(serverConfigJson, SafeJsonSettings); if (serverConfigObject == null) { continue; } string command = serverConfigObject.ContainsKey(McpConstants.JSON_KEY_COMMAND) ? serverConfigObject[McpConstants.JSON_KEY_COMMAND]?.ToString() ?? "" : ""; string[] args = new string[0]; if (serverConfigObject.ContainsKey(McpConstants.JSON_KEY_ARGS)) { string argsJson = JsonConvert.SerializeObject(serverConfigObject[McpConstants.JSON_KEY_ARGS], SafeJsonSettings); args = JsonConvert.DeserializeObject(argsJson, SafeJsonSettings) ?? new string[0]; } Dictionary env = new(); if (serverConfigObject.ContainsKey(McpConstants.JSON_KEY_ENV)) { string envJson = JsonConvert.SerializeObject(serverConfigObject[McpConstants.JSON_KEY_ENV], SafeJsonSettings); env = JsonConvert.DeserializeObject>(envJson, SafeJsonSettings) ?? new Dictionary(); } servers[serverName] = new McpServerConfigData(command, args, env); } return new McpConfig(servers); } /// /// Saves the mcp.json settings. /// public void Save(string configPath, McpConfig config) { Dictionary jsonStructure; bool fileExists = File.Exists(configPath); string existingContent = string.Empty; // If the file exists, retain its existing structure. if (fileExists) { existingContent = File.ReadAllText(configPath); // Security: Use safe settings for deserialization jsonStructure = JsonConvert.DeserializeObject>(existingContent, SafeJsonSettings) ?? new Dictionary(); } else { jsonStructure = new Dictionary(); } JObject mergedServers = GetExistingServersPreservingOrder(jsonStructure); foreach (KeyValuePair kvp in config.mcpServers) { if (!kvp.Key.StartsWith(McpConstants.PROJECT_NAME)) { continue; } mergedServers[kvp.Key] = JObject.FromObject(new { command = kvp.Value.command, args = kvp.Value.args, env = kvp.Value.env }); } jsonStructure[McpConstants.JSON_KEY_MCP_SERVERS] = mergedServers; // Security: Use safe settings for serialization string newJsonContent = JsonConvert.SerializeObject(jsonStructure, Formatting.Indented, SafeJsonSettings); // Only write if file doesn't exist or content has actually changed if (!fileExists || !IsConfigurationEqual(existingContent, config)) { File.WriteAllText(configPath, newJsonContent); } } /// /// Compares existing configuration content with new configuration to detect actual changes. /// Only compares uLoopMCP-related entries to avoid false positives from other MCP servers. /// /// Existing file content /// New configuration to compare /// True if uLoopMCP configurations are equal, false if different private bool IsConfigurationEqual(string existingContent, McpConfig newConfig) { if (string.IsNullOrWhiteSpace(existingContent)) { return false; } try { // Parse existing JSON structure Dictionary existingStructure = JsonConvert.DeserializeObject>(existingContent, SafeJsonSettings); if (existingStructure == null || !existingStructure.ContainsKey(McpConstants.JSON_KEY_MCP_SERVERS)) { return false; } // Extract existing uLoopMCP servers Dictionary existingULoopServers = ExtractULoopMCPServers(existingStructure); // Extract new uLoopMCP servers Dictionary newULoopServers = newConfig.mcpServers .Where(kvp => IsULoopMCPServer(kvp.Key, kvp.Value)) .ToDictionary( kvp => kvp.Key, kvp => (object)new { command = kvp.Value.command, args = kvp.Value.args, env = kvp.Value.env } ); // Convert to JToken and normalize object property order recursively JToken existingToken = NormalizeJToken(JToken.FromObject(existingULoopServers)); JToken newToken = NormalizeJToken(JToken.FromObject(newULoopServers)); // Order-insensitive deep equality comparison return JToken.DeepEquals(existingToken, newToken); } catch (System.Exception) { // If parsing fails, assume content is different return false; } } /// /// Recursively normalizes a JToken by sorting JObject properties by name. /// Array element order is preserved. /// private static JToken NormalizeJToken(JToken token) { if (token is JObject obj) { JObject sorted = new JObject(); foreach (JProperty prop in obj.Properties().OrderBy(p => p.Name, System.StringComparer.Ordinal)) { sorted.Add(prop.Name, NormalizeJToken(prop.Value)); } return sorted; } if (token is JArray arr) { JArray normalizedArray = new JArray(); foreach (JToken item in arr) { normalizedArray.Add(NormalizeJToken(item)); } return normalizedArray; } return token; // JValue and others } /// /// Extracts uLoopMCP server entries from existing JSON structure. /// /// Parsed JSON structure /// Dictionary containing only uLoopMCP server entries private Dictionary ExtractULoopMCPServers(Dictionary jsonStructure) { Dictionary uloopServers = new(); if (!jsonStructure.ContainsKey(McpConstants.JSON_KEY_MCP_SERVERS)) { return uloopServers; } string mcpServersJson = JsonConvert.SerializeObject(jsonStructure[McpConstants.JSON_KEY_MCP_SERVERS], SafeJsonSettings); Dictionary mcpServers = JsonConvert.DeserializeObject>(mcpServersJson, SafeJsonSettings); if (mcpServers == null) { return uloopServers; } foreach (KeyValuePair serverEntry in mcpServers) { if (!serverEntry.Key.StartsWith(McpConstants.PROJECT_NAME)) { continue; } // Check if it has UNITY_TCP_PORT environment variable (uLoopMCP marker) string serverJson = JsonConvert.SerializeObject(serverEntry.Value, SafeJsonSettings); Dictionary serverData = JsonConvert.DeserializeObject>(serverJson, SafeJsonSettings); if (serverData?.ContainsKey(McpConstants.JSON_KEY_ENV) == true) { string envJson = JsonConvert.SerializeObject(serverData[McpConstants.JSON_KEY_ENV], SafeJsonSettings); Dictionary env = JsonConvert.DeserializeObject>(envJson, SafeJsonSettings); if (env?.ContainsKey(McpConstants.UNITY_TCP_PORT_ENV_KEY) == true) { uloopServers[serverEntry.Key] = serverEntry.Value; } } } return uloopServers; } public void DeleteULoopMCPEntries(string configPath) { UnityEngine.Debug.Assert(!string.IsNullOrEmpty(configPath), "configPath must not be null or empty"); if (!File.Exists(configPath)) { return; } string existingContent = File.ReadAllText(configPath); if (string.IsNullOrWhiteSpace(existingContent) || existingContent.Length > McpConstants.MAX_JSON_SIZE_BYTES) { return; } Dictionary jsonStructure = JsonConvert.DeserializeObject>(existingContent, SafeJsonSettings); if (jsonStructure == null || !jsonStructure.ContainsKey(McpConstants.JSON_KEY_MCP_SERVERS)) { return; } JObject existingServers = JObject.FromObject(jsonStructure[McpConstants.JSON_KEY_MCP_SERVERS]); List keysToRemove = new(); foreach (JProperty serverEntry in existingServers.Properties()) { if (!serverEntry.Name.StartsWith(McpConstants.PROJECT_NAME)) { continue; } JToken envToken = serverEntry.Value?[McpConstants.JSON_KEY_ENV]; if (envToken?[McpConstants.UNITY_TCP_PORT_ENV_KEY] != null) { keysToRemove.Add(serverEntry.Name); } } if (keysToRemove.Count == 0) { return; } foreach (string key in keysToRemove) { existingServers.Remove(key); } jsonStructure[McpConstants.JSON_KEY_MCP_SERVERS] = existingServers; string newJsonContent = JsonConvert.SerializeObject(jsonStructure, Formatting.Indented, SafeJsonSettings); File.WriteAllText(configPath, newJsonContent); } /// /// JObject preserves insertion order, unlike Dictionary on .NET Framework 4.x. /// private JObject GetExistingServersPreservingOrder(Dictionary jsonStructure) { JObject servers = new(); if (!jsonStructure.ContainsKey(McpConstants.JSON_KEY_MCP_SERVERS)) { return servers; } JObject existingServers = JObject.FromObject(jsonStructure[McpConstants.JSON_KEY_MCP_SERVERS]); foreach (JProperty serverEntry in existingServers.Properties()) { servers[serverEntry.Name] = serverEntry.Value; } return servers; } /// /// Determines if a server entry is a uLoopMCP server. /// /// Server key /// Server configuration /// True if it's a uLoopMCP server private bool IsULoopMCPServer(string serverKey, McpServerConfigData serverConfig) { return serverKey.StartsWith(McpConstants.PROJECT_NAME) && serverConfig.env.ContainsKey(McpConstants.UNITY_TCP_PORT_ENV_KEY); } } }