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

390 lines
15 KiB
C#

using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace io.github.hatayama.uLoopMCP
{
/// <summary>
/// Class responsible for persisting MCP settings.
/// Single Responsibility Principle: Only responsible for reading and writing configuration files.
/// </summary>
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
};
/// <summary>
/// Checks if the configuration file exists.
/// </summary>
public bool Exists(string configPath)
{
return File.Exists(configPath);
}
/// <summary>
/// Creates the configuration directory.
/// </summary>
public void CreateConfigDirectory(string configPath)
{
string configDir = Path.GetDirectoryName(configPath);
if (string.IsNullOrEmpty(configDir) || Directory.Exists(configDir))
{
return;
}
Directory.CreateDirectory(configDir);
}
/// <summary>
/// Loads the mcp.json settings.
/// </summary>
public McpConfig Load(string configPath)
{
if (!File.Exists(configPath))
{
return new McpConfig(new Dictionary<string, McpServerConfigData>());
}
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<string, McpServerConfigData>());
}
// First, load the existing JSON as a dictionary with safe settings.
Dictionary<string, object> rootObject = JsonConvert.DeserializeObject<Dictionary<string, object>>(jsonContent, SafeJsonSettings);
Dictionary<string, McpServerConfigData> 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<string, object> mcpServersObject = JsonConvert.DeserializeObject<Dictionary<string, object>>(mcpServersJson, SafeJsonSettings);
if (mcpServersObject == null)
{
return new McpConfig(servers);
}
foreach (KeyValuePair<string, object> 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<string, object> serverConfigObject = JsonConvert.DeserializeObject<Dictionary<string, object>>(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<string[]>(argsJson, SafeJsonSettings) ?? new string[0];
}
Dictionary<string, string> env = new();
if (serverConfigObject.ContainsKey(McpConstants.JSON_KEY_ENV))
{
string envJson = JsonConvert.SerializeObject(serverConfigObject[McpConstants.JSON_KEY_ENV], SafeJsonSettings);
env = JsonConvert.DeserializeObject<Dictionary<string, string>>(envJson, SafeJsonSettings) ?? new Dictionary<string, string>();
}
servers[serverName] = new McpServerConfigData(command, args, env);
}
return new McpConfig(servers);
}
/// <summary>
/// Saves the mcp.json settings.
/// </summary>
public void Save(string configPath, McpConfig config)
{
Dictionary<string, object> 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<Dictionary<string, object>>(existingContent, SafeJsonSettings) ?? new Dictionary<string, object>();
}
else
{
jsonStructure = new Dictionary<string, object>();
}
JObject mergedServers = GetExistingServersPreservingOrder(jsonStructure);
foreach (KeyValuePair<string, McpServerConfigData> 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);
}
}
/// <summary>
/// Compares existing configuration content with new configuration to detect actual changes.
/// Only compares uLoopMCP-related entries to avoid false positives from other MCP servers.
/// </summary>
/// <param name="existingContent">Existing file content</param>
/// <param name="newConfig">New configuration to compare</param>
/// <returns>True if uLoopMCP configurations are equal, false if different</returns>
private bool IsConfigurationEqual(string existingContent, McpConfig newConfig)
{
if (string.IsNullOrWhiteSpace(existingContent))
{
return false;
}
try
{
// Parse existing JSON structure
Dictionary<string, object> existingStructure = JsonConvert.DeserializeObject<Dictionary<string, object>>(existingContent, SafeJsonSettings);
if (existingStructure == null || !existingStructure.ContainsKey(McpConstants.JSON_KEY_MCP_SERVERS))
{
return false;
}
// Extract existing uLoopMCP servers
Dictionary<string, object> existingULoopServers = ExtractULoopMCPServers(existingStructure);
// Extract new uLoopMCP servers
Dictionary<string, object> 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;
}
}
/// <summary>
/// Recursively normalizes a JToken by sorting JObject properties by name.
/// Array element order is preserved.
/// </summary>
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
}
/// <summary>
/// Extracts uLoopMCP server entries from existing JSON structure.
/// </summary>
/// <param name="jsonStructure">Parsed JSON structure</param>
/// <returns>Dictionary containing only uLoopMCP server entries</returns>
private Dictionary<string, object> ExtractULoopMCPServers(Dictionary<string, object> jsonStructure)
{
Dictionary<string, object> uloopServers = new();
if (!jsonStructure.ContainsKey(McpConstants.JSON_KEY_MCP_SERVERS))
{
return uloopServers;
}
string mcpServersJson = JsonConvert.SerializeObject(jsonStructure[McpConstants.JSON_KEY_MCP_SERVERS], SafeJsonSettings);
Dictionary<string, object> mcpServers = JsonConvert.DeserializeObject<Dictionary<string, object>>(mcpServersJson, SafeJsonSettings);
if (mcpServers == null)
{
return uloopServers;
}
foreach (KeyValuePair<string, object> 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<string, object> serverData = JsonConvert.DeserializeObject<Dictionary<string, object>>(serverJson, SafeJsonSettings);
if (serverData?.ContainsKey(McpConstants.JSON_KEY_ENV) == true)
{
string envJson = JsonConvert.SerializeObject(serverData[McpConstants.JSON_KEY_ENV], SafeJsonSettings);
Dictionary<string, string> env = JsonConvert.DeserializeObject<Dictionary<string, string>>(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<string, object> jsonStructure =
JsonConvert.DeserializeObject<Dictionary<string, object>>(existingContent, SafeJsonSettings);
if (jsonStructure == null || !jsonStructure.ContainsKey(McpConstants.JSON_KEY_MCP_SERVERS))
{
return;
}
JObject existingServers = JObject.FromObject(jsonStructure[McpConstants.JSON_KEY_MCP_SERVERS]);
List<string> 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);
}
/// <summary>
/// JObject preserves insertion order, unlike Dictionary on .NET Framework 4.x.
/// </summary>
private JObject GetExistingServersPreservingOrder(Dictionary<string, object> 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;
}
/// <summary>
/// Determines if a server entry is a uLoopMCP server.
/// </summary>
/// <param name="serverKey">Server key</param>
/// <param name="serverConfig">Server configuration</param>
/// <returns>True if it's a uLoopMCP server</returns>
private bool IsULoopMCPServer(string serverKey, McpServerConfigData serverConfig)
{
return serverKey.StartsWith(McpConstants.PROJECT_NAME)
&& serverConfig.env.ContainsKey(McpConstants.UNITY_TCP_PORT_ENV_KEY);
}
}
}