/*
┌──────────────────────────────────────────────────────────────────┐
│ 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
}
}
}