/* ┌──────────────────────────────────────────────────────────────────┐ │ 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.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using com.IvanMurzak.Unity.MCP.Runtime.Utils; using Microsoft.Extensions.Logging; using UnityEngine; namespace com.IvanMurzak.Unity.MCP { public partial class UnityMcpPluginEditor { public static string ResourcesFileName => "AI-Game-Developer-Config"; /// /// Project-relative path used by Unity's AssetDatabase API. /// Do NOT use this with System.IO File/Directory operations — use instead. /// public static string AssetsFilePath => $"UserSettings/{ResourcesFileName}.json"; /// /// Gets the Unity project root folder path (the folder that contains the Assets directory). /// Uses to ensure correctness regardless of the process working directory. /// public static string ProjectRootPath => Path.GetDirectoryName(Application.dataPath)!; /// /// Absolute path to the config file for use with System.IO File/Directory operations. /// Built from and to keep paths consistent. /// public static string AssetsFileAbsolutePath => Path.GetFullPath(Path.Combine(ProjectRootPath, AssetsFilePath)); /// /// Absolute path to the skills root folder for use with System.IO File/Directory operations. /// If is already an absolute path it is returned as-is; /// otherwise it is resolved relative to . /// public static string SkillsRootFolderAbsolutePath { get { var folder = SkillsPath; return Path.IsPathRooted(folder) ? folder : Path.GetFullPath(Path.Combine(ProjectRootPath, folder)); } } /// /// Resets the config file to its default state. /// public static void ResetConfig() { Instance.unityConnectionConfig.SetDefault(); var plugin = CurrentPlugin; if (plugin != null) Instance.ApplyConfigToMcpPlugin(plugin); Instance.Save(captureCurrentToolStates: false); } #if UNITY_EDITOR public static UnityEngine.TextAsset AssetFile => UnityEditor.AssetDatabase.LoadAssetAtPath(AssetsFilePath); #endif UnityConnectionConfig GetOrCreateConfig() => GetOrCreateConfig(out _); UnityConnectionConfig GetOrCreateConfig(out bool wasCreated) { wasCreated = false; try { // Both Edit mode and Play mode read from the same UserSettings JSON file // Use the absolute path so File.Exists/ReadAllText resolve correctly regardless of CWD. var json = File.Exists(AssetsFileAbsolutePath) ? File.ReadAllText(AssetsFileAbsolutePath) : null; UnityConnectionConfig? config = null; try { config = string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize(json!, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } }); } catch (Exception e) { _logger.LogCritical(e, "{method}: {file} file is corrupted at {path}", nameof(GetOrCreateConfig), ResourcesFileName, AssetsFilePath); } if (config == null) { _logger.LogWarning("{method}: Creating {file} file at {path}", nameof(GetOrCreateConfig), ResourcesFileName, AssetsFilePath); config = new UnityConnectionConfig(); wasCreated = true; } if (string.IsNullOrEmpty(config.LocalToken)) { config.LocalToken = GenerateToken(); wasCreated = true; } // Auto-heal a legacy absolute `SkillsPath` that points inside the project root. // Committing `UserSettings/AI-Game-Developer-Config.json` is supposed to be portable // across machines; a machine-specific absolute path breaks every other team member. // The normaliser also flips backslashes to forward slashes for cross-platform diff // stability. Absolute paths OUTSIDE the project root are intentional user choices // and are left untouched. Re-saving runs only when normalisation actually changed // the value, so a project with an already-relative SkillsPath does not get touched. if (!string.IsNullOrEmpty(config.SkillsPath)) { var normalizedSkills = NormalizeSkillsPath(config.SkillsPath); if (!string.Equals(normalizedSkills, config.SkillsPath, StringComparison.Ordinal)) { _logger.LogInformation( "{method}: migrating SkillsPath from {old} to {new}", nameof(GetOrCreateConfig), config.SkillsPath, normalizedSkills); config.SkillsPath = normalizedSkills; wasCreated = true; } } return config; } catch (Exception e) { _logger.LogCritical(e, "{method}: {file} file can't be loaded from {path}", nameof(GetOrCreateConfig), ResourcesFileName, AssetsFilePath); throw; } } public void Save(bool captureCurrentToolStates = true) { #if UNITY_EDITOR Validate(); try { var directory = Path.GetDirectoryName(AssetsFileAbsolutePath); if (!Directory.Exists(directory)) Directory.CreateDirectory(directory!); unityConnectionConfig ??= new UnityConnectionConfig(); if (captureCurrentToolStates) { var enabledToolNames = Tools?.GetAllTools() ?.Select(t => new UnityConnectionConfig.McpFeature(t.Name, Tools.IsToolEnabled(t.Name))) ?.ToList(); var enabledPromptNames = Prompts?.GetAllPrompts() ?.Select(p => new UnityConnectionConfig.McpFeature(p.Name, Prompts.IsPromptEnabled(p.Name))) ?.ToList(); var enabledResourceNames = Resources?.GetAllResources() ?.Select(r => new UnityConnectionConfig.McpFeature(r.Name, Resources.IsResourceEnabled(r.Name))) ?.ToList(); unityConnectionConfig.Tools = enabledToolNames != null && enabledToolNames.Count > 0 ? enabledToolNames : UnityConnectionConfig.DefaultTools; unityConnectionConfig.Prompts = enabledPromptNames != null && enabledPromptNames.Count > 0 ? enabledPromptNames : UnityConnectionConfig.DefaultPrompts; unityConnectionConfig.Resources = enabledResourceNames != null && enabledResourceNames.Count > 0 ? enabledResourceNames : UnityConnectionConfig.DefaultResources; } // Runtime-only overrides (env vars / CLI flags) MUST NOT be persisted to disk. // Temporarily restore the disk-baseline values for any overridden field, serialize, // then re-apply the overrides so the in-memory config keeps its runtime values. // The "in-memory keeps overrides" property is critical: callers (UI, configurators, // SignalR client) hold references to unityConnectionConfig and continue reading // from it after Save returns. Restoring overrides ensures behaviour is unchanged. var hasRuntimeOverrides = RuntimeOverrides != null && RuntimeOverrides.HasAny; if (hasRuntimeOverrides) EnvironmentUtils.ApplyBaseline(unityConnectionConfig, RuntimeOverrides!); try { var json = JsonSerializer.Serialize(unityConnectionConfig, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } }); File.WriteAllText(AssetsFileAbsolutePath, json); } finally { if (hasRuntimeOverrides) EnvironmentUtils.ApplyOverrides(unityConnectionConfig, RuntimeOverrides!); } var assetFile = AssetFile; if (assetFile != null) UnityEditor.EditorUtility.SetDirty(assetFile); else UnityEditor.AssetDatabase.ImportAsset(AssetsFilePath); } catch (Exception e) { _logger.LogCritical(e, "{method}: {file} file can't be saved at {path}", nameof(Save), ResourcesFileName, AssetsFilePath); } #else // do nothing in runtime builds return; #endif } } }