chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes files atomically via temp file + rename to prevent
|
||||
/// external processes (e.g. CLI) from reading partially-written data.
|
||||
/// </summary>
|
||||
internal static class AtomicFileWriter
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes content atomically: .tmp → .bak → target.
|
||||
/// </summary>
|
||||
public static void Write(string filePath, string content)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty");
|
||||
Debug.Assert(content != null, "content must not be null");
|
||||
|
||||
string tempFilePath = filePath + ".tmp";
|
||||
string backupFilePath = filePath + ".bak";
|
||||
File.WriteAllText(tempFilePath, content);
|
||||
|
||||
// .NET Framework 4.7.1 lacks File.Move(src, dst, overwrite), so we
|
||||
// rotate old → .bak before moving .tmp → target to minimize the window
|
||||
// where the target file is absent for external readers (CLI).
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
if (File.Exists(backupFilePath))
|
||||
{
|
||||
File.Delete(backupFilePath);
|
||||
}
|
||||
File.Move(filePath, backupFilePath);
|
||||
}
|
||||
File.Move(tempFilePath, filePath);
|
||||
}
|
||||
|
||||
internal static void RecoverSidecarFiles(string filePath)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(filePath), "filePath must not be null or empty");
|
||||
|
||||
string tempFilePath = filePath + ".tmp";
|
||||
string backupFilePath = filePath + ".bak";
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
CleanupBackup(backupFilePath);
|
||||
CleanupTemp(tempFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// .tmp is newer than .bak once the previous target has been rotated away,
|
||||
// so preferring it avoids rolling back to older session state after a crash.
|
||||
if (File.Exists(tempFilePath))
|
||||
{
|
||||
File.Move(tempFilePath, filePath);
|
||||
CleanupBackup(backupFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (File.Exists(backupFilePath))
|
||||
{
|
||||
File.Move(backupFilePath, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
public static void CleanupBackup(string backupFilePath)
|
||||
{
|
||||
if (File.Exists(backupFilePath))
|
||||
{
|
||||
File.Delete(backupFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupTemp(string tempFilePath)
|
||||
{
|
||||
if (File.Exists(tempFilePath))
|
||||
{
|
||||
File.Delete(tempFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 624dd200a07ed4014a6b3ee4dcdc422e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,357 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration service for Codex ({projectRoot}/.codex/config.toml), TOML string-based.
|
||||
/// </summary>
|
||||
public sealed class CodexTomlConfigService : IMcpConfigService
|
||||
{
|
||||
private static readonly Regex SectionRegex = new Regex(
|
||||
@"(?ms)^\[mcp_servers\.uLoopMCP\]\s*.*?(?=^\[|\z)",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex AnyMcpServerRegex = new Regex(
|
||||
@"(?ms)^\[mcp_servers\.[^\]]+\]\s*.*?(?=^\[|\z)",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex Arg0Regex = new Regex(
|
||||
@"(?ms)^\[mcp_servers\.uLoopMCP\].*?^\s*args\s*=\s*\[\s*(['""])(?<arg0>[^'""]+)\1",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex PortRegex = new Regex(
|
||||
@"env\s*=\s*\{[^}]*""UNITY_TCP_PORT""\s*=\s*['""]?(?<port>\d+)['""]?",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
// Inner-scope regexes for parsing within the matched uLoopMCP section
|
||||
private static readonly Regex Arg0InnerRegex = new Regex(
|
||||
@"(?m)^\s*args\s*=\s*\[\s*(['""])(?<arg0>[^'""]+)\1",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex PortInnerRegex = new Regex(
|
||||
@"(?m)^\s*env\s*=\s*\{[^\}]*""UNITY_TCP_PORT""\s*=\s*['""]?(?<port>\d+)['""]?",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex EnvLineRegex = new Regex(
|
||||
@"(?m)^\s*env\s*=\s*\{(?<body>[^\}]*)\}",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex EnvPairRegex = new Regex(
|
||||
@"(?m)""(?<key>[^""]+)""\s*=\s*(?:""(?<value>[^""]+)""|(?<value>[^,}\r\n]+))",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public bool IsConfigured()
|
||||
{
|
||||
string path = UnityMcpPathResolver.GetCodexConfigPath();
|
||||
if (!File.Exists(path)) return false;
|
||||
string content = File.ReadAllText(path);
|
||||
return SectionRegex.IsMatch(content);
|
||||
}
|
||||
|
||||
public bool IsUpdateNeeded(int port)
|
||||
{
|
||||
string path = UnityMcpPathResolver.GetCodexConfigPath();
|
||||
if (!File.Exists(path)) return true;
|
||||
string content = File.ReadAllText(path);
|
||||
|
||||
string serverAbsolutePath = UnityMcpPathResolver.GetTypeScriptServerPath();
|
||||
if (string.IsNullOrEmpty(serverAbsolutePath) || !File.Exists(serverAbsolutePath)) return false;
|
||||
string expectedArg0 = UnityMcpPathResolver.MakeRelativeToConfigurationRoot(serverAbsolutePath);
|
||||
|
||||
(string arg0, int? existingPort) = ReadCurrentValues(content);
|
||||
if (string.IsNullOrEmpty(arg0) || existingPort == null) return true;
|
||||
|
||||
string normalizedArg0 = NormalizeForCompare(arg0);
|
||||
string normalizedExpected = NormalizeForCompare(expectedArg0);
|
||||
if (!string.Equals(normalizedArg0, normalizedExpected, System.StringComparison.OrdinalIgnoreCase) || existingPort.Value != port) return true;
|
||||
|
||||
// When compiled with ULOOPMCP_DEBUG, require debug env flags to be present
|
||||
#if ULOOPMCP_DEBUG
|
||||
Match section = SectionRegex.Match(content);
|
||||
if (section.Success)
|
||||
{
|
||||
System.Collections.Generic.Dictionary<string, string> envPairs = ParseEnvPairs(section.Value);
|
||||
bool hasMcpDebug = envPairs.TryGetValue("MCP_DEBUG", out string debugVal) && string.Equals(debugVal, "true", System.StringComparison.Ordinal);
|
||||
bool hasNodeOptions = envPairs.TryGetValue("NODE_OPTIONS", out string nodeOpt) && string.Equals(nodeOpt, "--enable-source-maps", System.StringComparison.Ordinal);
|
||||
if (!hasMcpDebug || !hasNodeOptions) return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void AutoConfigure(int port)
|
||||
{
|
||||
string path = UnityMcpPathResolver.GetCodexConfigPath();
|
||||
EnsureDirectory(path);
|
||||
string content = File.Exists(path) ? File.ReadAllText(path) : string.Empty;
|
||||
|
||||
string serverPath = UnityMcpPathResolver.GetTypeScriptServerPath();
|
||||
if (string.IsNullOrEmpty(serverPath) || !File.Exists(serverPath))
|
||||
{
|
||||
throw new System.InvalidOperationException("TypeScript server bundle path not found.");
|
||||
}
|
||||
// Use relative path for better portability (config is now project-level)
|
||||
string relativeServerPath = UnityMcpPathResolver.MakeRelativeToConfigurationRoot(serverPath);
|
||||
|
||||
string block = BuildBlock(port, relativeServerPath);
|
||||
|
||||
string result;
|
||||
if (SectionRegex.IsMatch(content))
|
||||
{
|
||||
result = SectionRegex.Replace(content, block.TrimEnd() + System.Environment.NewLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
var matches = AnyMcpServerRegex.Matches(content);
|
||||
if (matches.Count > 0)
|
||||
{
|
||||
var last = matches[matches.Count - 1];
|
||||
int insertIndex = last.Index + last.Length;
|
||||
result = content.Insert(insertIndex, System.Environment.NewLine + block);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = string.IsNullOrWhiteSpace(content) ? block : content.TrimEnd() + System.Environment.NewLine + System.Environment.NewLine + block;
|
||||
}
|
||||
}
|
||||
|
||||
File.WriteAllText(path, result);
|
||||
}
|
||||
|
||||
public int GetConfiguredPort()
|
||||
{
|
||||
string path = UnityMcpPathResolver.GetCodexConfigPath();
|
||||
if (!File.Exists(path)) throw new System.InvalidOperationException("Configuration file not found.");
|
||||
string content = File.ReadAllText(path);
|
||||
(_, int? port) = ReadCurrentValues(content);
|
||||
if (port == null) throw new System.InvalidOperationException("UNITY_TCP_PORT not found.");
|
||||
return port.Value;
|
||||
}
|
||||
|
||||
public void DeleteConfiguration()
|
||||
{
|
||||
string path = UnityMcpPathResolver.GetCodexConfigPath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string content = File.ReadAllText(path);
|
||||
string result = SectionRegex.Replace(content, string.Empty);
|
||||
if (ReferenceEquals(content, result))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
result = Regex.Replace(result, @"(\r?\n){3,}", System.Environment.NewLine + System.Environment.NewLine);
|
||||
File.WriteAllText(path, result);
|
||||
}
|
||||
|
||||
public void UpdateDevelopmentSettings(int port, bool developmentMode, bool enableMcpLogs)
|
||||
{
|
||||
string path = UnityMcpPathResolver.GetCodexConfigPath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
AutoConfigure(port);
|
||||
}
|
||||
|
||||
string content = File.ReadAllText(path);
|
||||
|
||||
// If section not exists, create it first and reload
|
||||
if (!SectionRegex.IsMatch(content))
|
||||
{
|
||||
AutoConfigure(port);
|
||||
content = File.ReadAllText(path);
|
||||
}
|
||||
|
||||
// Replace only within the uLoopMCP section and update dev logs settings
|
||||
Match section = SectionRegex.Match(content);
|
||||
if (!section.Success)
|
||||
{
|
||||
// As a last attempt, try to autoconfigure and reload once
|
||||
AutoConfigure(port);
|
||||
content = File.ReadAllText(path);
|
||||
section = SectionRegex.Match(content);
|
||||
if (!section.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
string updatedSection = UpdateSectionWithDevelopmentSettings(section.Value, port, developmentMode, enableMcpLogs);
|
||||
string newContent = content.Substring(0, section.Index) + updatedSection + content.Substring(section.Index + section.Length);
|
||||
File.WriteAllText(path, newContent);
|
||||
}
|
||||
|
||||
private static (string arg0, int? port) ReadCurrentValues(string content)
|
||||
{
|
||||
Match section = SectionRegex.Match(content);
|
||||
if (!section.Success) return (null, null);
|
||||
|
||||
string arg0 = null;
|
||||
int? port = null;
|
||||
|
||||
Match a = Arg0InnerRegex.Match(section.Value);
|
||||
if (a.Success)
|
||||
{
|
||||
arg0 = a.Groups["arg0"].Value;
|
||||
}
|
||||
|
||||
Match p = PortInnerRegex.Match(section.Value);
|
||||
if (p.Success)
|
||||
{
|
||||
int value;
|
||||
if (int.TryParse(p.Groups["port"].Value, out value))
|
||||
{
|
||||
port = value;
|
||||
}
|
||||
}
|
||||
|
||||
return (arg0, port);
|
||||
}
|
||||
|
||||
private static string BuildBlock(int port, string serverPath)
|
||||
{
|
||||
// Use single-quoted TOML literal string for args so backslashes don't need escaping
|
||||
string literalPath = serverPath.Replace("'", "''");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.AppendLine("[mcp_servers.uLoopMCP]");
|
||||
sb.AppendLine("command = \"node\"");
|
||||
sb.Append("args = ['").Append(literalPath).AppendLine("']");
|
||||
System.Text.StringBuilder envLine = new System.Text.StringBuilder();
|
||||
envLine.Append("env = { \"UNITY_TCP_PORT\" = \"").Append(port.ToString()).Append("\"");
|
||||
#if ULOOPMCP_DEBUG
|
||||
envLine.Append(", \"MCP_DEBUG\" = \"true\", \"NODE_OPTIONS\" = \"--enable-source-maps\"");
|
||||
#endif
|
||||
envLine.AppendLine(" }");
|
||||
sb.Append(envLine.ToString());
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string NormalizeForCompare(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return path;
|
||||
// Normalize to forward slashes for comparison (single-quoted TOML writes raw backslashes)
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string UpdateSectionWithDevelopmentSettings(string sectionText, int port, bool developmentMode, bool enableMcpLogs)
|
||||
{
|
||||
// Ensure env line exists and update key-values
|
||||
Match envMatch = EnvLineRegex.Match(sectionText);
|
||||
string envBody = envMatch.Success ? envMatch.Groups["body"].Value : string.Empty;
|
||||
|
||||
System.Collections.Generic.Dictionary<string, string> pairs = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||
|
||||
if (!string.IsNullOrEmpty(envBody))
|
||||
{
|
||||
MatchCollection mc = EnvPairRegex.Matches(envBody);
|
||||
foreach (Match m in mc)
|
||||
{
|
||||
string key = m.Groups["key"].Value;
|
||||
string value = m.Groups["value"].Value;
|
||||
if (!pairs.ContainsKey(key))
|
||||
{
|
||||
pairs.Add(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update mandatory port
|
||||
pairs["UNITY_TCP_PORT"] = port.ToString();
|
||||
|
||||
// Clean legacy flags
|
||||
pairs.Remove("ULOOPMCP_DEBUG");
|
||||
pairs.Remove("ULOOPMCP_PRODUCTION");
|
||||
|
||||
// Apply MCP debug logs
|
||||
if (enableMcpLogs)
|
||||
{
|
||||
pairs["MCP_DEBUG"] = "true";
|
||||
pairs["NODE_OPTIONS"] = "--enable-source-maps";
|
||||
}
|
||||
else
|
||||
{
|
||||
pairs.Remove("MCP_DEBUG");
|
||||
pairs.Remove("NODE_OPTIONS");
|
||||
}
|
||||
|
||||
// Rebuild env line with deterministic ordering
|
||||
System.Collections.Generic.List<string> orderedKeys = new System.Collections.Generic.List<string>();
|
||||
if (pairs.ContainsKey("UNITY_TCP_PORT")) orderedKeys.Add("UNITY_TCP_PORT");
|
||||
if (pairs.ContainsKey("MCP_DEBUG")) orderedKeys.Add("MCP_DEBUG");
|
||||
if (pairs.ContainsKey("NODE_OPTIONS")) orderedKeys.Add("NODE_OPTIONS");
|
||||
foreach (System.Collections.Generic.KeyValuePair<string, string> kv in pairs)
|
||||
{
|
||||
if (orderedKeys.Contains(kv.Key)) continue;
|
||||
orderedKeys.Add(kv.Key);
|
||||
}
|
||||
|
||||
System.Text.StringBuilder envBuilder = new System.Text.StringBuilder();
|
||||
for (int i = 0; i < orderedKeys.Count; i++)
|
||||
{
|
||||
string key = orderedKeys[i];
|
||||
string value = pairs[key];
|
||||
if (i > 0) envBuilder.Append(", ");
|
||||
envBuilder.Append("\"").Append(key).Append("\"").Append(" = ");
|
||||
envBuilder.Append("\"").Append(value).Append("\"");
|
||||
}
|
||||
string newEnvLine = "env = { " + envBuilder.ToString() + " }";
|
||||
|
||||
string updatedSection;
|
||||
if (envMatch.Success)
|
||||
{
|
||||
updatedSection = sectionText.Substring(0, envMatch.Index) + newEnvLine + sectionText.Substring(envMatch.Index + envMatch.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Insert after args line if exists, otherwise append at end
|
||||
Match argsMatch = Arg0InnerRegex.Match(sectionText);
|
||||
if (argsMatch.Success)
|
||||
{
|
||||
int insertIndex = argsMatch.Index + argsMatch.Length;
|
||||
// Find end of line
|
||||
int lineEnd = sectionText.IndexOf('\n', insertIndex);
|
||||
if (lineEnd < 0) lineEnd = insertIndex;
|
||||
string prefix = sectionText.Substring(0, lineEnd + 1);
|
||||
string suffix = sectionText.Substring(lineEnd + 1);
|
||||
updatedSection = prefix + newEnvLine + System.Environment.NewLine + suffix;
|
||||
}
|
||||
else
|
||||
{
|
||||
updatedSection = sectionText.TrimEnd() + System.Environment.NewLine + newEnvLine + System.Environment.NewLine;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedSection;
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.Dictionary<string, string> ParseEnvPairs(string sectionText)
|
||||
{
|
||||
System.Collections.Generic.Dictionary<string, string> pairs = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.Ordinal);
|
||||
Match envMatch = EnvLineRegex.Match(sectionText);
|
||||
if (!envMatch.Success) return pairs;
|
||||
|
||||
string envBody = envMatch.Groups["body"].Value;
|
||||
if (string.IsNullOrEmpty(envBody)) return pairs;
|
||||
|
||||
MatchCollection mc = EnvPairRegex.Matches(envBody);
|
||||
foreach (Match m in mc)
|
||||
{
|
||||
string key = m.Groups["key"].Value;
|
||||
string value = m.Groups["value"].Value;
|
||||
if (!pairs.ContainsKey(key))
|
||||
{
|
||||
pairs.Add(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
private static void EnsureDirectory(string filePath)
|
||||
{
|
||||
string dir = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4a70e78768ae245d59d047c171c90b01
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Constants definition for dynamic code execution
|
||||
/// Related Classes: IDynamicCodeExecutor, DynamicCodeExecutor, DynamicCodeExecutorStub
|
||||
/// </summary>
|
||||
public static class DynamicCodeConstants
|
||||
{
|
||||
/// <summary>Default name for dynamically generated classes</summary>
|
||||
public const string DEFAULT_CLASS_NAME = "DynamicCommand";
|
||||
|
||||
/// <summary>Default namespace for dynamically generated classes</summary>
|
||||
public const string DEFAULT_NAMESPACE = "uLoopMCP.Dynamic";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4fcde60743f8a4f4eacd487b0e4cd05f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Value object that stores editor configuration information.
|
||||
///
|
||||
/// Related classes:
|
||||
/// - McpEditorType: Defines supported editor types
|
||||
/// - EditorConfigProvider: Visitor pattern implementation that creates instances of this class
|
||||
/// - UnityMcpPathResolver: Path resolution logic (functionality to be migrated to this class)
|
||||
/// - McpConstants: Client name constants (functionality to be migrated to this class)
|
||||
/// </summary>
|
||||
public readonly struct EditorConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Client name of the editor (e.g., "Cursor", "Claude Code")
|
||||
/// </summary>
|
||||
public string ClientName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path to the configuration file
|
||||
/// </summary>
|
||||
public string ConfigPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path to the configuration directory (null for project root)
|
||||
/// </summary>
|
||||
public string ConfigDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the EditorConfig struct.
|
||||
/// </summary>
|
||||
/// <param name="clientName">Client name of the editor</param>
|
||||
/// <param name="configPath">Absolute path to the configuration file</param>
|
||||
/// <param name="configDirectory">Absolute path to the configuration directory</param>
|
||||
public EditorConfig(string clientName, string configPath, string configDirectory)
|
||||
{
|
||||
ClientName = clientName;
|
||||
ConfigPath = configPath;
|
||||
ConfigDirectory = configDirectory;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4dc7a8ec399614c7685b8823da872e77
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides centralized configuration management for different MCP editors using the Visitor pattern.
|
||||
///
|
||||
/// This class eliminates switch statements scattered across the codebase by centralizing
|
||||
/// all editor-specific configuration logic in one place. When adding a new editor type,
|
||||
/// you only need to:
|
||||
/// 1. Add the new enum value to McpEditorType
|
||||
/// 2. Add corresponding Visit method to IEditorConfigVisitor interface
|
||||
/// 3. Implement the new Visit method in this class
|
||||
///
|
||||
/// Related classes:
|
||||
/// - McpEditorType: Enum that defines supported editor types
|
||||
/// - EditorConfig: Value object that stores configuration information
|
||||
/// - IEditorConfigVisitor: Visitor interface for type-safe operations
|
||||
/// - UnityMcpPathResolver: Path resolution methods (to be refactored to use this class)
|
||||
/// - McpConstants: Client name constants (to be refactored to use this class)
|
||||
/// </summary>
|
||||
public static class EditorConfigProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the configuration for the specified editor type using the Visitor pattern.
|
||||
/// </summary>
|
||||
/// <param name="editorType">The editor type to get configuration for</param>
|
||||
/// <returns>EditorConfig containing client name, config path, and config directory</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the editor type is not supported</exception>
|
||||
public static EditorConfig GetConfig(McpEditorType editorType)
|
||||
{
|
||||
var visitor = new ConfigurationVisitor();
|
||||
return editorType.Accept(visitor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the client name for the specified editor type.
|
||||
/// </summary>
|
||||
/// <param name="editorType">The editor type to get client name for</param>
|
||||
/// <returns>The client name string</returns>
|
||||
public static string GetClientName(McpEditorType editorType)
|
||||
{
|
||||
return GetConfig(editorType).ClientName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration file path for the specified editor type.
|
||||
/// </summary>
|
||||
/// <param name="editorType">The editor type to get config path for</param>
|
||||
/// <returns>The absolute path to the configuration file</returns>
|
||||
public static string GetConfigPath(McpEditorType editorType)
|
||||
{
|
||||
return GetConfig(editorType).ConfigPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration directory path for the specified editor type.
|
||||
/// </summary>
|
||||
/// <param name="editorType">The editor type to get config directory for</param>
|
||||
/// <returns>The absolute path to the configuration directory, or null if not applicable</returns>
|
||||
public static string GetConfigDirectory(McpEditorType editorType)
|
||||
{
|
||||
return GetConfig(editorType).ConfigDirectory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Concrete implementation of the Visitor pattern for editor configuration.
|
||||
/// </summary>
|
||||
private sealed class ConfigurationVisitor : IEditorConfigVisitor<EditorConfig>
|
||||
{
|
||||
public EditorConfig VisitCursor()
|
||||
{
|
||||
return new EditorConfig(
|
||||
McpConstants.CLIENT_NAME_CURSOR,
|
||||
UnityMcpPathResolver.GetMcpConfigPath(),
|
||||
UnityMcpPathResolver.GetCursorConfigDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
public EditorConfig VisitClaudeCode()
|
||||
{
|
||||
return new EditorConfig(
|
||||
McpConstants.CLIENT_NAME_CLAUDE_CODE,
|
||||
UnityMcpPathResolver.GetClaudeCodeConfigPath(),
|
||||
null // Claude Code config is in project root
|
||||
);
|
||||
}
|
||||
|
||||
public EditorConfig VisitVSCode()
|
||||
{
|
||||
return new EditorConfig(
|
||||
McpConstants.CLIENT_NAME_VSCODE,
|
||||
UnityMcpPathResolver.GetVSCodeConfigPath(),
|
||||
UnityMcpPathResolver.GetVSCodeConfigDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
public EditorConfig VisitGeminiCLI()
|
||||
{
|
||||
return new EditorConfig(
|
||||
McpConstants.CLIENT_NAME_GEMINI_CLI,
|
||||
UnityMcpPathResolver.GetGeminiCLIConfigPath(),
|
||||
UnityMcpPathResolver.GetGeminiConfigDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
public EditorConfig VisitWindsurf()
|
||||
{
|
||||
return new EditorConfig(
|
||||
McpConstants.CLIENT_NAME_WINDSURF,
|
||||
UnityMcpPathResolver.GetWindsurfConfigPath(),
|
||||
UnityMcpPathResolver.GetWindsurfConfigDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
public EditorConfig VisitCodex()
|
||||
{
|
||||
return new EditorConfig(
|
||||
McpConstants.CLIENT_NAME_CODEX,
|
||||
UnityMcpPathResolver.GetCodexConfigPath(),
|
||||
UnityMcpPathResolver.GetCodexConfigDirectory()
|
||||
);
|
||||
}
|
||||
|
||||
public EditorConfig VisitMcpInspector()
|
||||
{
|
||||
return new EditorConfig(
|
||||
McpConstants.CLIENT_NAME_MCP_INSPECTOR,
|
||||
UnityMcpPathResolver.GetMcpInspectorConfigPath(),
|
||||
null // MCP Inspector config is in project root
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for McpEditorType to support the Visitor pattern.
|
||||
/// </summary>
|
||||
public static class McpEditorTypeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Accepts a visitor and dispatches to the appropriate Visit method based on the editor type.
|
||||
/// This method eliminates the need for switch statements when working with McpEditorType.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The return type of the visitor operation</typeparam>
|
||||
/// <param name="editorType">The editor type to dispatch</param>
|
||||
/// <param name="visitor">The visitor to accept</param>
|
||||
/// <returns>The result of the visitor operation</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the editor type is not supported</exception>
|
||||
public static T Accept<T>(this McpEditorType editorType, IEditorConfigVisitor<T> visitor)
|
||||
{
|
||||
return editorType switch
|
||||
{
|
||||
McpEditorType.Cursor => visitor.VisitCursor(),
|
||||
McpEditorType.ClaudeCode => visitor.VisitClaudeCode(),
|
||||
McpEditorType.VSCode => visitor.VisitVSCode(),
|
||||
McpEditorType.GeminiCLI => visitor.VisitGeminiCLI(),
|
||||
McpEditorType.Windsurf => visitor.VisitWindsurf(),
|
||||
McpEditorType.Codex => visitor.VisitCodex(),
|
||||
McpEditorType.McpInspector => visitor.VisitMcpInspector(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(editorType), editorType, null),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3d295b0b4f7104e35b652a7bf9bf5455
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Visitor interface for handling editor-specific configuration generation.
|
||||
///
|
||||
/// This interface enables the Visitor pattern to eliminate switch statements
|
||||
/// when converting McpEditorType enum values to their corresponding settings.
|
||||
/// Each editor type has its own Visit method that returns type T.
|
||||
///
|
||||
/// Related classes:
|
||||
/// - McpEditorType: Enum that defines supported editor types
|
||||
/// - EditorConfig: Value object that stores configuration information
|
||||
/// - EditorConfigProvider: Concrete implementation of this visitor interface
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The return type for visit operations</typeparam>
|
||||
public interface IEditorConfigVisitor<out T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Visits Cursor editor configuration.
|
||||
/// </summary>
|
||||
/// <returns>Configuration result for Cursor</returns>
|
||||
T VisitCursor();
|
||||
|
||||
/// <summary>
|
||||
/// Visits Claude Code editor configuration.
|
||||
/// </summary>
|
||||
/// <returns>Configuration result for Claude Code</returns>
|
||||
T VisitClaudeCode();
|
||||
|
||||
/// <summary>
|
||||
/// Visits VSCode editor configuration.
|
||||
/// </summary>
|
||||
/// <returns>Configuration result for VSCode</returns>
|
||||
T VisitVSCode();
|
||||
|
||||
/// <summary>
|
||||
/// Visits Gemini CLI editor configuration.
|
||||
/// </summary>
|
||||
/// <returns>Configuration result for Gemini CLI</returns>
|
||||
T VisitGeminiCLI();
|
||||
|
||||
/// <summary>
|
||||
/// Visits Windsurf editor configuration.
|
||||
/// </summary>
|
||||
/// <returns>Configuration result for Windsurf</returns>
|
||||
T VisitWindsurf();
|
||||
|
||||
/// <summary>
|
||||
/// Visits Codex editor configuration.
|
||||
/// </summary>
|
||||
/// <returns>Configuration result for Codex</returns>
|
||||
T VisitCodex();
|
||||
|
||||
/// <summary>
|
||||
/// Visits MCP Inspector configuration.
|
||||
/// </summary>
|
||||
/// <returns>Configuration result for MCP Inspector</returns>
|
||||
T VisitMcpInspector();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e42e2ea5eb5a74ef381c42a8a9f220e3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstraction for MCP editor configuration services.
|
||||
/// Provides uniform API for JSON- and TOML-based editors.
|
||||
/// </summary>
|
||||
public interface IMcpConfigService
|
||||
{
|
||||
bool IsConfigured();
|
||||
bool IsUpdateNeeded(int port);
|
||||
void AutoConfigure(int port);
|
||||
int GetConfiguredPort();
|
||||
void UpdateDevelopmentSettings(int port, bool developmentMode, bool enableMcpLogs);
|
||||
void DeleteConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ebc00e6a5bc9345e994b8d62e67c5b91
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Data structure for MCP settings.
|
||||
/// An immutable class representing the structure of the mcp.json file.
|
||||
/// </summary>
|
||||
public class McpConfig
|
||||
{
|
||||
public readonly Dictionary<string, McpServerConfigData> mcpServers;
|
||||
|
||||
public McpConfig(Dictionary<string, McpServerConfigData> mcpServers)
|
||||
{
|
||||
this.mcpServers = mcpServers;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data structure for MCP server settings.
|
||||
/// An immutable class representing the configuration information for each server.
|
||||
/// </summary>
|
||||
public class McpServerConfigData
|
||||
{
|
||||
public readonly string command;
|
||||
public readonly string[] args;
|
||||
public readonly Dictionary<string, string> env;
|
||||
|
||||
public McpServerConfigData(string command, string[] args, Dictionary<string, string> env)
|
||||
{
|
||||
this.command = command;
|
||||
this.args = args;
|
||||
this.env = env;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 22c6c65b1ee7c4e3198401a1c9e4c06b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Class responsible for auto-updating MCP configuration files.
|
||||
/// Automatically updates configuration files when paths change after package updates.
|
||||
/// Single Responsibility Principle: Only responsible for auto-updating configuration files.
|
||||
///
|
||||
/// Related classes:
|
||||
/// - McpConfigServiceFactory: Provides ConfigService for each editor type
|
||||
/// - McpConfigService: Handles configuration file read/write and update detection
|
||||
/// - McpServerOperations: Calls this class during server startup
|
||||
/// </summary>
|
||||
public static class McpConfigAutoUpdater
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks all configured editors and updates their configuration files if needed.
|
||||
/// Automatically updates configurations when paths have changed (e.g., after package updates).
|
||||
/// </summary>
|
||||
/// <param name="port">The port number to use</param>
|
||||
public static void UpdateAllConfiguredEditors(int port)
|
||||
{
|
||||
McpConfigServiceFactory factory = new McpConfigServiceFactory();
|
||||
IReadOnlyDictionary<McpEditorType, IMcpConfigService> allServices = factory.GetAllConfigServices();
|
||||
|
||||
foreach (KeyValuePair<McpEditorType, IMcpConfigService> entry in allServices)
|
||||
{
|
||||
UpdateEditorConfigIfNeeded(entry.Key, entry.Value, port);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the specified editor's configuration if needed.
|
||||
/// </summary>
|
||||
/// <param name="editorType">The editor type</param>
|
||||
/// <param name="configService">The configuration service</param>
|
||||
/// <param name="port">The port number</param>
|
||||
private static void UpdateEditorConfigIfNeeded(McpEditorType editorType, IMcpConfigService configService, int port)
|
||||
{
|
||||
bool isConfigured = configService.IsConfigured();
|
||||
if (!isConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool isUpdateNeeded = configService.IsUpdateNeeded(port);
|
||||
if (!isUpdateNeeded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
configService.AutoConfigure(port);
|
||||
string editorName = GetEditorDisplayName(editorType);
|
||||
Debug.Log($"[uLoopMCP] Auto-updated {editorName} configuration (path changed)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name for the editor type.
|
||||
/// </summary>
|
||||
/// <param name="editorType">The editor type</param>
|
||||
/// <returns>The display name</returns>
|
||||
private static string GetEditorDisplayName(McpEditorType editorType)
|
||||
{
|
||||
return editorType switch
|
||||
{
|
||||
McpEditorType.Cursor => "Cursor",
|
||||
McpEditorType.ClaudeCode => "Claude Code",
|
||||
McpEditorType.VSCode => "VSCode",
|
||||
McpEditorType.GeminiCLI => "Gemini CLI",
|
||||
McpEditorType.Windsurf => "Windsurf",
|
||||
McpEditorType.Codex => "Codex",
|
||||
McpEditorType.McpInspector => "MCP Inspector",
|
||||
_ => editorType.ToString()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3a3b3c96615941bdada5ad85cb78555
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,390 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 180d305a8cf744775931f29008a2d438
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,422 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// A class responsible for the business logic of MCP settings.
|
||||
/// Single Responsibility Principle: Responsible only for the business logic of settings management.
|
||||
///
|
||||
/// Design document reference: Packages/src/Editor/ARCHITECTURE.md
|
||||
///
|
||||
/// Related classes:
|
||||
/// - McpConfigRepository: Handles file I/O operations for configuration persistence
|
||||
/// - McpServerConfigFactory: Creates server configuration objects with proper formatting
|
||||
/// - McpConfigServiceFactory: Factory that creates and manages McpConfigService instances
|
||||
/// - UnityMcpPathResolver: Provides configuration file paths for different editors
|
||||
/// - McpServerConfigComparer: Compares server configurations to detect changes
|
||||
/// - McpPortValidator: Validates port numbers for configuration
|
||||
/// - McpEditorSettings: Manages editor-specific settings like custom port
|
||||
/// </summary>
|
||||
public class McpConfigService : IMcpConfigService
|
||||
{
|
||||
private readonly McpConfigRepository _repository;
|
||||
private readonly McpEditorType _editorType;
|
||||
|
||||
public McpConfigService(McpConfigRepository repository, McpEditorType editorType)
|
||||
{
|
||||
_repository = repository;
|
||||
_editorType = editorType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the editor settings exist.
|
||||
/// </summary>
|
||||
public bool IsConfigured()
|
||||
{
|
||||
string configPath = UnityMcpPathResolver.GetConfigPath(_editorType);
|
||||
if (!_repository.Exists(configPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
McpConfig config = _repository.Load(configPath);
|
||||
// Check if a setting with a port number exists.
|
||||
return config.mcpServers.Keys.Any(key => key.StartsWith(McpConstants.PROJECT_NAME));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if update is needed for the editor settings.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number to check.</param>
|
||||
/// <returns>True if update is needed, false if settings are already up to date.</returns>
|
||||
public bool IsUpdateNeeded(int port)
|
||||
{
|
||||
|
||||
string configPath = UnityMcpPathResolver.GetConfigPath(_editorType);
|
||||
|
||||
// If configuration file doesn't exist, update is needed
|
||||
if (!_repository.Exists(configPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Load existing settings
|
||||
McpConfig config = _repository.Load(configPath);
|
||||
|
||||
// Check if Unity MCP configuration exists
|
||||
string serverKey = McpServerConfigFactory.CreateUnityMcpServerKey(port, _editorType);
|
||||
if (!config.mcpServers.ContainsKey(serverKey))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create expected configuration
|
||||
string serverPath = UnityMcpPathResolver.GetTypeScriptServerPath();
|
||||
McpServerConfigData expectedConfig = McpServerConfigFactory.CreateUnityMcpConfig(port, serverPath, _editorType);
|
||||
|
||||
// Compare with existing configuration
|
||||
McpServerConfigData existingConfig = config.mcpServers[serverKey];
|
||||
bool configsAreEqual = McpServerConfigComparer.AreEqual(existingConfig, expectedConfig);
|
||||
|
||||
return !configsAreEqual;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-configures the editor settings.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number to use.</param>
|
||||
public void AutoConfigure(int port)
|
||||
{
|
||||
if (!NodePathResolver.IsNodeAvailable())
|
||||
{
|
||||
UnityEditor.EditorUtility.DisplayDialog(
|
||||
"Node.js Not Found",
|
||||
"Node.js is not installed or PATH is not configured.\n\n" +
|
||||
"To verify, run 'node -v' in your terminal.\n\n" +
|
||||
"You can download Node.js from: https://nodejs.org/",
|
||||
"OK"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
string configPath = UnityMcpPathResolver.GetConfigPath(_editorType);
|
||||
|
||||
// Create the settings directory (only if necessary).
|
||||
_repository.CreateConfigDirectory(configPath);
|
||||
|
||||
// Load existing settings (or create new ones if they don't exist).
|
||||
McpConfig config = _repository.Load(configPath);
|
||||
|
||||
// For Windsurf, keep the original behavior (always create new key)
|
||||
// For other editors, remove existing uLoopMCP configuration and create new one with updated port
|
||||
string serverKey = McpServerConfigFactory.CreateUnityMcpServerKey(port, _editorType);
|
||||
bool shouldReplaceExistingKey = _editorType != McpEditorType.Windsurf;
|
||||
|
||||
Dictionary<string, McpServerConfigData> updatedServers = new(config.mcpServers);
|
||||
|
||||
if (shouldReplaceExistingKey)
|
||||
{
|
||||
// Try to find existing uLoopMCP configuration
|
||||
string existingKey = FindExistingULoopMCPConfigurationKey(config);
|
||||
if (!string.IsNullOrEmpty(existingKey) && existingKey != serverKey)
|
||||
{
|
||||
// Remove existing configuration with different key
|
||||
updatedServers.Remove(existingKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new settings.
|
||||
string serverPath = UnityMcpPathResolver.GetTypeScriptServerPath();
|
||||
McpServerConfigData newConfig = McpServerConfigFactory.CreateUnityMcpConfig(port, serverPath, _editorType);
|
||||
|
||||
// Compare with existing settings and update if there are differences.
|
||||
bool needsUpdate = true;
|
||||
if (updatedServers.ContainsKey(serverKey))
|
||||
{
|
||||
McpServerConfigData existingConfig = updatedServers[serverKey];
|
||||
needsUpdate = !McpServerConfigComparer.AreEqual(existingConfig, newConfig);
|
||||
|
||||
if (needsUpdate)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate)
|
||||
{
|
||||
// Add/update Unity MCP settings.
|
||||
updatedServers[serverKey] = newConfig;
|
||||
|
||||
McpConfig updatedConfig = new(updatedServers);
|
||||
_repository.Save(configPath, updatedConfig);
|
||||
|
||||
string editorName = GetEditorDisplayName(_editorType);
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteConfiguration()
|
||||
{
|
||||
string configPath = UnityMcpPathResolver.GetConfigPath(_editorType);
|
||||
_repository.DeleteULoopMCPEntries(configPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds existing Unity MCP configuration key in the loaded config.
|
||||
/// Returns the first found uLoopMCP configuration key, or null if none exists.
|
||||
/// </summary>
|
||||
/// <param name="config">The loaded MCP configuration</param>
|
||||
/// <returns>Existing uLoopMCP configuration key, or null if not found</returns>
|
||||
private string FindExistingULoopMCPConfigurationKey(McpConfig config)
|
||||
{
|
||||
foreach (System.Collections.Generic.KeyValuePair<string, McpServerConfigData> serverEntry in config.mcpServers)
|
||||
{
|
||||
if (!serverEntry.Key.StartsWith(McpConstants.PROJECT_NAME)) continue;
|
||||
|
||||
if (!serverEntry.Value.env.ContainsKey(McpConstants.UNITY_TCP_PORT_ENV_KEY)) continue;
|
||||
|
||||
// Found existing uLoopMCP configuration
|
||||
return serverEntry.Key;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates environment variables for development mode and MCP debug logs.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number to use.</param>
|
||||
/// <param name="developmentMode">Whether to enable development mode.</param>
|
||||
/// <param name="enableMcpLogs">Whether to enable MCP debug logs.</param>
|
||||
public void UpdateDevelopmentSettings(int port, bool developmentMode, bool enableMcpLogs)
|
||||
{
|
||||
string configPath = UnityMcpPathResolver.GetConfigPath(_editorType);
|
||||
|
||||
// Create the settings directory (only if necessary)
|
||||
_repository.CreateConfigDirectory(configPath);
|
||||
|
||||
// Load existing settings (or create new ones if they don't exist)
|
||||
McpConfig config = _repository.Load(configPath);
|
||||
|
||||
// For Windsurf, keep the original behavior (always use port-based key)
|
||||
// For other editors, remove existing uLoopMCP configuration and create new one with updated port
|
||||
string serverKey = McpServerConfigFactory.CreateUnityMcpServerKey(port, _editorType);
|
||||
bool shouldReplaceExistingKey = _editorType != McpEditorType.Windsurf;
|
||||
|
||||
Dictionary<string, McpServerConfigData> updatedServers = new(config.mcpServers);
|
||||
|
||||
if (shouldReplaceExistingKey)
|
||||
{
|
||||
// Try to find existing uLoopMCP configuration
|
||||
string existingKey = FindExistingULoopMCPConfigurationKey(config);
|
||||
if (!string.IsNullOrEmpty(existingKey) && existingKey != serverKey)
|
||||
{
|
||||
// Remove existing configuration with different key
|
||||
updatedServers.Remove(existingKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if our server configuration exists
|
||||
if (!updatedServers.ContainsKey(serverKey))
|
||||
{
|
||||
// If it doesn't exist, create it with full configuration
|
||||
AutoConfigure(port);
|
||||
// Then update the development settings
|
||||
UpdateDevelopmentSettingsOnly(port, developmentMode, enableMcpLogs);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update only the development settings
|
||||
UpdateDevelopmentSettingsOnly(port, developmentMode, enableMcpLogs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates only the environment variables for development mode and MCP debug logs.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number.</param>
|
||||
/// <param name="developmentMode">Whether to enable development mode.</param>
|
||||
/// <param name="enableMcpLogs">Whether to enable MCP debug logs.</param>
|
||||
private void UpdateDevelopmentSettingsOnly(int port, bool developmentMode, bool enableMcpLogs)
|
||||
{
|
||||
string configPath = UnityMcpPathResolver.GetConfigPath(_editorType);
|
||||
|
||||
// Load existing settings
|
||||
McpConfig config = _repository.Load(configPath);
|
||||
|
||||
// For Windsurf, keep the original behavior (always use port-based key)
|
||||
// For other editors, remove existing uLoopMCP configuration and create new one with updated port
|
||||
string serverKey = McpServerConfigFactory.CreateUnityMcpServerKey(port, _editorType);
|
||||
bool shouldReplaceExistingKey = _editorType != McpEditorType.Windsurf;
|
||||
|
||||
Dictionary<string, McpServerConfigData> updatedServers = new(config.mcpServers);
|
||||
|
||||
if (shouldReplaceExistingKey)
|
||||
{
|
||||
// Try to find existing uLoopMCP configuration
|
||||
string existingKey = FindExistingULoopMCPConfigurationKey(config);
|
||||
if (!string.IsNullOrEmpty(existingKey) && existingKey != serverKey)
|
||||
{
|
||||
// Remove existing configuration with different key
|
||||
updatedServers.Remove(existingKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!updatedServers.ContainsKey(serverKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing configuration
|
||||
McpServerConfigData existingConfig = updatedServers[serverKey];
|
||||
|
||||
// Create new environment variables based on existing ones
|
||||
Dictionary<string, string> updatedEnv = new(existingConfig.env);
|
||||
|
||||
// Update the port number in environment variables
|
||||
updatedEnv[McpConstants.UNITY_TCP_PORT_ENV_KEY] = port.ToString();
|
||||
|
||||
// Remove old development mode environment variables (cleanup legacy settings)
|
||||
updatedEnv.Remove(McpConstants.ENV_KEY_ULOOPMCP_DEBUG);
|
||||
updatedEnv.Remove(McpConstants.ENV_KEY_ULOOPMCP_PRODUCTION);
|
||||
updatedEnv.Remove(McpConstants.ENV_KEY_MCP_DEBUG);
|
||||
updatedEnv.Remove(McpConstants.ENV_KEY_NODE_OPTIONS);
|
||||
|
||||
// MCP_CLIENT_NAME is no longer used - client identification handled by MCP protocol
|
||||
|
||||
// Add MCP_DEBUG for MCP debug logs
|
||||
if (enableMcpLogs)
|
||||
{
|
||||
updatedEnv[McpConstants.ENV_KEY_MCP_DEBUG] = McpConstants.ENV_VALUE_TRUE;
|
||||
// Enable source maps when debug mode is enabled for better stack traces
|
||||
updatedEnv[McpConstants.ENV_KEY_NODE_OPTIONS] = McpConstants.NODE_OPTIONS_ENABLE_SOURCE_MAPS;
|
||||
}
|
||||
// For disabled logs, simply don't set MCP_DEBUG and NODE_OPTIONS (default behavior)
|
||||
|
||||
// Create updated configuration (keeping command and args unchanged)
|
||||
McpServerConfigData updatedConfig = new McpServerConfigData(
|
||||
existingConfig.command,
|
||||
existingConfig.args,
|
||||
updatedEnv
|
||||
);
|
||||
|
||||
// Update only this server's configuration
|
||||
updatedServers[serverKey] = updatedConfig;
|
||||
|
||||
// Save the updated configuration
|
||||
McpConfig updatedMcpConfig = new(updatedServers);
|
||||
_repository.Save(configPath, updatedMcpConfig);
|
||||
|
||||
string editorName = GetEditorDisplayName(_editorType);
|
||||
|
||||
// Log environment variables for debugging
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name of the editor.
|
||||
/// </summary>
|
||||
private string GetEditorDisplayName(McpEditorType editorType)
|
||||
{
|
||||
return editorType switch
|
||||
{
|
||||
McpEditorType.Cursor => "Cursor",
|
||||
McpEditorType.ClaudeCode => "Claude Code",
|
||||
_ => editorType.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured port number from the editor settings.
|
||||
/// If server is running, tries to find config matching the current server port.
|
||||
/// Otherwise returns the first available configuration.
|
||||
/// </summary>
|
||||
/// <returns>The configured port number.</returns>
|
||||
public int GetConfiguredPort()
|
||||
{
|
||||
McpConfig config = LoadConfiguration();
|
||||
System.Collections.Generic.List<(string key, int port)> uloopmcpConfigs = ExtractULoopMCPConfigurations(config);
|
||||
return SelectBestMatchingPort(uloopmcpConfigs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load configuration file from the appropriate path
|
||||
/// </summary>
|
||||
/// <returns>The loaded MCP configuration</returns>
|
||||
private McpConfig LoadConfiguration()
|
||||
{
|
||||
string configPath = UnityMcpPathResolver.GetConfigPath(_editorType);
|
||||
|
||||
if (!_repository.Exists(configPath))
|
||||
{
|
||||
throw new System.InvalidOperationException("Configuration file not found.");
|
||||
}
|
||||
|
||||
return _repository.Load(configPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract Unity MCP configurations from the loaded config
|
||||
/// </summary>
|
||||
/// <param name="config">The loaded MCP configuration</param>
|
||||
/// <returns>List of Unity MCP server configurations with their ports</returns>
|
||||
private System.Collections.Generic.List<(string key, int port)> ExtractULoopMCPConfigurations(McpConfig config)
|
||||
{
|
||||
System.Collections.Generic.List<(string key, int port)> uloopmcpConfigs = new System.Collections.Generic.List<(string key, int port)>();
|
||||
|
||||
foreach (System.Collections.Generic.KeyValuePair<string, McpServerConfigData> serverEntry in config.mcpServers)
|
||||
{
|
||||
if (!serverEntry.Key.StartsWith(McpConstants.PROJECT_NAME)) continue;
|
||||
|
||||
if (!serverEntry.Value.env.ContainsKey(McpConstants.UNITY_TCP_PORT_ENV_KEY)) continue;
|
||||
|
||||
string portString = serverEntry.Value.env[McpConstants.UNITY_TCP_PORT_ENV_KEY];
|
||||
if (int.TryParse(portString, out int port))
|
||||
{
|
||||
uloopmcpConfigs.Add((serverEntry.Key, port));
|
||||
}
|
||||
}
|
||||
|
||||
if (uloopmcpConfigs.Count == 0)
|
||||
{
|
||||
throw new System.InvalidOperationException("Unity MCP server configuration not found.");
|
||||
}
|
||||
|
||||
return uloopmcpConfigs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select the best matching port based on server status
|
||||
/// </summary>
|
||||
/// <param name="uloopmcpConfigs">Available Unity MCP configurations</param>
|
||||
/// <returns>The most appropriate port number</returns>
|
||||
private int SelectBestMatchingPort(System.Collections.Generic.List<(string key, int port)> uloopmcpConfigs)
|
||||
{
|
||||
bool serverIsRunning = McpServerController.IsServerRunning;
|
||||
|
||||
if (!serverIsRunning) return uloopmcpConfigs[0].port;
|
||||
|
||||
int currentServerPort = McpServerController.ServerPort;
|
||||
(string key, int port) matchingConfig = uloopmcpConfigs.FirstOrDefault(c => c.port == currentServerPort);
|
||||
|
||||
return matchingConfig != default ? matchingConfig.port : uloopmcpConfigs[0].port;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates configuration parameters for fail-fast behavior
|
||||
/// </summary>
|
||||
private void ValidateConfigurationParameters(int port)
|
||||
{
|
||||
// Validate port number using shared validator
|
||||
if (!McpPortValidator.ValidatePort(port, $"for {_editorType} configuration"))
|
||||
{
|
||||
throw new System.ArgumentOutOfRangeException(nameof(port),
|
||||
$"Invalid port number for {_editorType} configuration: {port}. Port must be between 1 and 65535.");
|
||||
}
|
||||
|
||||
// Validate editor type
|
||||
if (!System.Enum.IsDefined(typeof(McpEditorType), _editorType))
|
||||
{
|
||||
throw new System.InvalidOperationException(
|
||||
$"Cannot configure settings for invalid editor type: {_editorType}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db4bbeeb39d1a4b51b2cf56ccccb7842
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Factory for creating and managing MCP configuration services
|
||||
/// Centralizes configuration service creation and caching
|
||||
/// Related classes:
|
||||
/// - McpConfigService: Configuration service for specific editor types
|
||||
/// - McpConfigRepository: Repository for configuration data
|
||||
/// - McpEditorType: Enumeration of supported editor types
|
||||
/// </summary>
|
||||
public class McpConfigServiceFactory
|
||||
{
|
||||
private readonly Dictionary<McpEditorType, IMcpConfigService> _configServices;
|
||||
|
||||
public McpConfigServiceFactory()
|
||||
{
|
||||
_configServices = new Dictionary<McpEditorType, IMcpConfigService>();
|
||||
InitializeAllServices();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get configuration service for specified editor type
|
||||
/// </summary>
|
||||
/// <param name="editorType">Editor type</param>
|
||||
/// <returns>Configuration service</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when unsupported editor type is specified</exception>
|
||||
public IMcpConfigService GetConfigService(McpEditorType editorType)
|
||||
{
|
||||
if (_configServices.TryGetValue(editorType, out IMcpConfigService service))
|
||||
{
|
||||
return service;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Unsupported editor type: {editorType}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all available configuration services
|
||||
/// </summary>
|
||||
/// <returns>Read-only collection of all configuration services</returns>
|
||||
public IReadOnlyDictionary<McpEditorType, IMcpConfigService> GetAllConfigServices()
|
||||
{
|
||||
return _configServices;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize all configuration services
|
||||
/// Automatically creates services for all defined McpEditorType enum values
|
||||
/// </summary>
|
||||
private void InitializeAllServices()
|
||||
{
|
||||
McpEditorType[] allEditorTypes = (McpEditorType[])Enum.GetValues(typeof(McpEditorType));
|
||||
|
||||
foreach (McpEditorType editorType in allEditorTypes)
|
||||
{
|
||||
IMcpConfigService service;
|
||||
if (editorType == McpEditorType.Codex)
|
||||
{
|
||||
service = new CodexTomlConfigService();
|
||||
}
|
||||
else
|
||||
{
|
||||
McpConfigRepository repository = new();
|
||||
service = new McpConfigService(repository, editorType);
|
||||
}
|
||||
_configServices[editorType] = service;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76229b809bc6c4d63a85cab5965ac4ca
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically updates MCP configuration files when ULOOPMCP_DEBUG scripting define symbol changes
|
||||
///
|
||||
/// Design document reference: Packages/src/Editor/ARCHITECTURE.md
|
||||
///
|
||||
/// Related classes:
|
||||
/// - McpServerController: Calls this class during OnAfterAssemblyReload
|
||||
/// - McpConfigService: Used to update individual editor configurations
|
||||
/// - ULoopMCPDebugToggle: Provides the debug symbol toggle functionality
|
||||
/// - McpConfigServiceFactory: Creates configuration services for different editors
|
||||
/// </summary>
|
||||
public static class McpDebugStateUpdater
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates all configured MCP editor settings to match current ULOOPMCP_DEBUG state
|
||||
/// Called during domain reload to ensure configurations stay in sync
|
||||
/// </summary>
|
||||
public static void UpdateAllConfigurationsForDebugState()
|
||||
{
|
||||
bool isDebugMode = IsDebugModeEnabled();
|
||||
string correlationId = VibeLogger.GenerateCorrelationId();
|
||||
|
||||
VibeLogger.LogInfo(
|
||||
"debug_state_update_start",
|
||||
$"Starting MCP configuration update for debug state: {isDebugMode}",
|
||||
new { debug_mode = isDebugMode },
|
||||
correlationId,
|
||||
"Automatic update triggered by domain reload",
|
||||
"Monitor which editor configurations are updated"
|
||||
);
|
||||
|
||||
int updatedCount = 0;
|
||||
McpConfigServiceFactory factory = new();
|
||||
|
||||
// Check all editor types for existing configurations
|
||||
foreach (McpEditorType editorType in Enum.GetValues(typeof(McpEditorType)))
|
||||
{
|
||||
IMcpConfigService configService = factory.GetConfigService(editorType);
|
||||
|
||||
// Only update if configuration already exists
|
||||
if (configService.IsConfigured())
|
||||
{
|
||||
// Get the current port from existing configuration
|
||||
int currentPort = configService.GetConfiguredPort();
|
||||
|
||||
// Update the configuration to match debug state
|
||||
configService.UpdateDevelopmentSettings(currentPort, false, isDebugMode);
|
||||
updatedCount++;
|
||||
|
||||
VibeLogger.LogInfo(
|
||||
"debug_state_editor_updated",
|
||||
$"Updated {editorType} configuration for debug mode: {isDebugMode}",
|
||||
new { editor_type = editorType.ToString(), port = currentPort, debug_mode = isDebugMode },
|
||||
correlationId,
|
||||
$"{editorType} settings synchronized with debug state",
|
||||
"Verify that MCP_DEBUG environment variable is correctly set"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
VibeLogger.LogInfo(
|
||||
"debug_state_update_complete",
|
||||
$"Completed MCP configuration update. Updated {updatedCount} editor configurations.",
|
||||
new { debug_mode = isDebugMode, updated_count = updatedCount },
|
||||
correlationId,
|
||||
"Automatic debug state synchronization completed",
|
||||
"All configured editors should now have consistent debug settings"
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if ULOOPMCP_DEBUG scripting define symbol is currently defined
|
||||
/// </summary>
|
||||
/// <returns>True if debug mode is enabled, false otherwise</returns>
|
||||
private static bool IsDebugModeEnabled()
|
||||
{
|
||||
bool isULoopMcpDebug;
|
||||
#if ULOOPMCP_DEBUG
|
||||
isULoopMcpDebug = true;
|
||||
#else
|
||||
isULoopMcpDebug = false;
|
||||
#endif
|
||||
return isULoopMcpDebug;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 868eb3bbbea7f4d5485258dd9c64c052
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,30 @@
|
||||
using UnityEditor;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
public sealed class McpEditorDomainReloadStateProvider : IDomainReloadStateProvider
|
||||
{
|
||||
private static volatile bool _isDomainReloadInProgress;
|
||||
|
||||
public bool IsDomainReloadInProgress()
|
||||
{
|
||||
return _isDomainReloadInProgress;
|
||||
}
|
||||
|
||||
public static void SetDomainReloadInProgressFromMainThread(bool isDomainReloadInProgress)
|
||||
{
|
||||
_isDomainReloadInProgress = isDomainReloadInProgress;
|
||||
}
|
||||
}
|
||||
|
||||
public static class McpEditorDomainReloadStateRegistration
|
||||
{
|
||||
[InitializeOnLoadMethod]
|
||||
private static void Register()
|
||||
{
|
||||
McpEditorDomainReloadStateProvider.SetDomainReloadInProgressFromMainThread(
|
||||
McpEditorSettings.GetIsDomainReloadInProgress());
|
||||
DomainReloadStateRegistry.RegisterProvider(new McpEditorDomainReloadStateProvider());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8134fd2d31bde432e9b69459511ee909
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,892 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security;
|
||||
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Compile request data for session management.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class CompileRequestData
|
||||
{
|
||||
public string requestId;
|
||||
public string json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unity MCP Editor settings data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public record McpEditorSettingsData
|
||||
{
|
||||
public int customPort = McpServerConfig.DEFAULT_PORT;
|
||||
public string projectRootPath = "";
|
||||
public string serverSessionId = "";
|
||||
public bool showDeveloperTools = false;
|
||||
public bool enableMcpLogs = false;
|
||||
public bool enableCommunicationLogs = false;
|
||||
public bool enableDevelopmentMode = false;
|
||||
public string lastUsedConfigPath = "";
|
||||
public string lastSeenSetupWizardVersion = "";
|
||||
public bool suppressSetupWizardAutoShow = false;
|
||||
|
||||
// UI State Settings
|
||||
public bool showSecuritySettings = true;
|
||||
public bool showToolSettings = true;
|
||||
|
||||
// Repository Root Toggle
|
||||
public bool addRepositoryRoot = false;
|
||||
// Default to flat installation so first-time setup does not add an extra grouping layer unless requested.
|
||||
public bool installSkillsFlat = true;
|
||||
|
||||
// Session State Settings (moved from McpSessionManager)
|
||||
// Default to true so the server starts automatically on fresh install
|
||||
public bool isServerRunning = true;
|
||||
public bool isAfterCompile = false;
|
||||
public bool isDomainReloadInProgress = false;
|
||||
public bool isReconnecting = false;
|
||||
public bool showReconnectingUI = false;
|
||||
public bool showPostCompileReconnectingUI = false;
|
||||
public int selectedEditorType = (int)McpEditorType.Cursor;
|
||||
public int connectionMode = (int)ConnectionMode.CLI;
|
||||
public float communicationLogHeight = McpUIConstants.DEFAULT_COMMUNICATION_LOG_HEIGHT;
|
||||
public string communicationLogsJson = "[]";
|
||||
public string pendingRequestsJson = "{}";
|
||||
public bool compileWindowHasData = false;
|
||||
public string[] pendingCompileRequestIds = new string[0];
|
||||
public CompileRequestData[] compileRequests = new CompileRequestData[0];
|
||||
public ConnectedLLMToolData[] connectedLLMTools = new ConnectedLLMToolData[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Management class for Unity MCP Editor settings.
|
||||
/// Saves as a JSON file in the UserSettings folder.
|
||||
/// </summary>
|
||||
public static class McpEditorSettings
|
||||
{
|
||||
private static string SettingsFilePath => Path.Combine(McpConstants.USER_SETTINGS_FOLDER, McpConstants.SETTINGS_FILE_NAME);
|
||||
|
||||
private static McpEditorSettingsData _cachedSettings;
|
||||
|
||||
internal static void InvalidateCache()
|
||||
{
|
||||
_cachedSettings = null;
|
||||
}
|
||||
|
||||
[InitializeOnLoadMethod]
|
||||
private static void RecoverSettingsFileOnEditorLoad()
|
||||
{
|
||||
if (AssetDatabase.IsAssetImportWorkerProcess())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RecoverSettingsFileIfNeeded();
|
||||
}
|
||||
|
||||
internal static void RecoverSettingsFileIfNeeded()
|
||||
{
|
||||
if (!IsValidSettingsPath(SettingsFilePath))
|
||||
{
|
||||
throw new SecurityException($"Invalid settings file path: {SettingsFilePath}");
|
||||
}
|
||||
|
||||
AtomicFileWriter.RecoverSidecarFiles(SettingsFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the settings data.
|
||||
/// </summary>
|
||||
public static McpEditorSettingsData GetSettings()
|
||||
{
|
||||
if (_cachedSettings == null)
|
||||
{
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
return _cachedSettings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the settings data.
|
||||
/// </summary>
|
||||
public static void SaveSettings(McpEditorSettingsData settings)
|
||||
{
|
||||
// Security: Validate settings file path
|
||||
if (!IsValidSettingsPath(SettingsFilePath))
|
||||
{
|
||||
throw new SecurityException($"Invalid settings file path: {SettingsFilePath}");
|
||||
}
|
||||
|
||||
// Security: Ensure directory exists and create it safely
|
||||
string directory = Path.GetDirectoryName(SettingsFilePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
string json = JsonUtility.ToJson(settings, true);
|
||||
|
||||
// Security: Validate JSON content size
|
||||
if (json.Length > McpConstants.MAX_SETTINGS_SIZE_BYTES)
|
||||
{
|
||||
throw new SecurityException("Settings JSON content exceeds size limit");
|
||||
}
|
||||
|
||||
AtomicFileWriter.Write(SettingsFilePath, json);
|
||||
_cachedSettings = settings;
|
||||
|
||||
// Best-effort cleanup: even if this fails, .bak is overwritten on next save
|
||||
AtomicFileWriter.CleanupBackup(SettingsFilePath + ".bak");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a transformation to the current settings and saves once.
|
||||
/// Use when multiple fields need to be updated together to avoid redundant writes.
|
||||
/// </summary>
|
||||
public static void UpdateSettings(Func<McpEditorSettingsData, McpEditorSettingsData> transform)
|
||||
{
|
||||
Debug.Assert(transform != null, "transform must not be null");
|
||||
|
||||
McpEditorSettingsData current = GetSettings();
|
||||
McpEditorSettingsData updated = transform(current);
|
||||
SaveSettings(updated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the custom port number.
|
||||
/// </summary>
|
||||
public static int GetCustomPort()
|
||||
{
|
||||
return GetSettings().customPort;
|
||||
}
|
||||
|
||||
public static string GetProjectRootPath()
|
||||
{
|
||||
return GetSettings().projectRootPath ?? string.Empty;
|
||||
}
|
||||
|
||||
public static string GetServerSessionId()
|
||||
{
|
||||
return GetSettings().serverSessionId ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the custom port number.
|
||||
/// </summary>
|
||||
public static void SetCustomPort(int port)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData updatedSettings = settings with { customPort = port };
|
||||
SaveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
public static void SetRunningServerSession(int port, string projectRootPath, string serverSessionId)
|
||||
{
|
||||
Debug.Assert(port > 0, "port must be positive");
|
||||
Debug.Assert(!string.IsNullOrWhiteSpace(projectRootPath), "projectRootPath must not be empty");
|
||||
Debug.Assert(!string.IsNullOrWhiteSpace(serverSessionId), "serverSessionId must not be empty");
|
||||
|
||||
string normalizedProjectRootPath = NormalizeProjectRootPath(projectRootPath);
|
||||
UpdateSettings(settings => settings with
|
||||
{
|
||||
isServerRunning = true,
|
||||
customPort = port,
|
||||
projectRootPath = normalizedProjectRootPath,
|
||||
serverSessionId = serverSessionId
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Developer Tools display setting.
|
||||
/// </summary>
|
||||
public static bool GetShowDeveloperTools()
|
||||
{
|
||||
return GetSettings().showDeveloperTools;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the Developer Tools display setting.
|
||||
/// </summary>
|
||||
public static void SetShowDeveloperTools(bool show)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData updatedSettings = settings with { showDeveloperTools = show };
|
||||
SaveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the MCP log enabled flag.
|
||||
/// </summary>
|
||||
public static bool GetEnableMcpLogs()
|
||||
{
|
||||
return GetSettings().enableMcpLogs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the MCP log enabled flag.
|
||||
/// </summary>
|
||||
public static void SetEnableMcpLogs(bool enableMcpLogs)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { enableMcpLogs = enableMcpLogs };
|
||||
SaveSettings(newSettings);
|
||||
|
||||
// Synchronize McpLogger settings as well.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the communication logs enabled flag.
|
||||
/// </summary>
|
||||
public static bool GetEnableCommunicationLogs()
|
||||
{
|
||||
return GetSettings().enableCommunicationLogs;
|
||||
}
|
||||
|
||||
public static string GetLastSeenSetupWizardVersion()
|
||||
{
|
||||
return GetSettings().lastSeenSetupWizardVersion ?? string.Empty;
|
||||
}
|
||||
|
||||
public static void SetLastSeenSetupWizardVersion(string version)
|
||||
{
|
||||
string normalizedVersion = version ?? string.Empty;
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData updatedSettings = settings with { lastSeenSetupWizardVersion = normalizedVersion };
|
||||
SaveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
public static bool GetSuppressSetupWizardAutoShow()
|
||||
{
|
||||
return GetSettings().suppressSetupWizardAutoShow;
|
||||
}
|
||||
|
||||
public static void SetSuppressSetupWizardAutoShow(bool suppressAutoShow)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData updatedSettings = settings with { suppressSetupWizardAutoShow = suppressAutoShow };
|
||||
SaveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the communication logs enabled flag.
|
||||
/// </summary>
|
||||
public static void SetEnableCommunicationLogs(bool enableCommunicationLogs)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { enableCommunicationLogs = enableCommunicationLogs };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the show security settings flag.
|
||||
/// </summary>
|
||||
public static bool GetShowSecuritySettings()
|
||||
{
|
||||
return GetSettings().showSecuritySettings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the show security settings flag.
|
||||
/// </summary>
|
||||
public static void SetShowSecuritySettings(bool showSecuritySettings)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { showSecuritySettings = showSecuritySettings };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
public static bool GetShowToolSettings()
|
||||
{
|
||||
return GetSettings().showToolSettings;
|
||||
}
|
||||
|
||||
public static void SetShowToolSettings(bool showToolSettings)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { showToolSettings = showToolSettings };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the repository root usage flag.
|
||||
/// </summary>
|
||||
public static bool GetAddRepositoryRoot()
|
||||
{
|
||||
return GetSettings().addRepositoryRoot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the repository root usage flag.
|
||||
/// </summary>
|
||||
public static void SetAddRepositoryRoot(bool addRepositoryRoot)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { addRepositoryRoot = addRepositoryRoot };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
public static bool GetInstallSkillsFlat()
|
||||
{
|
||||
return GetSettings().installSkillsFlat;
|
||||
}
|
||||
|
||||
public static void SetInstallSkillsFlat(bool installSkillsFlat)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { installSkillsFlat = installSkillsFlat };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
// Session State Settings Methods (moved from McpSessionManager)
|
||||
|
||||
/// <summary>
|
||||
/// Gets the server running state.
|
||||
/// </summary>
|
||||
public static bool GetIsServerRunning()
|
||||
{
|
||||
return GetSettings().isServerRunning;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the server running state.
|
||||
/// </summary>
|
||||
public static void SetIsServerRunning(bool isServerRunning)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { isServerRunning = isServerRunning };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the after compile flag.
|
||||
/// </summary>
|
||||
public static bool GetIsAfterCompile()
|
||||
{
|
||||
return GetSettings().isAfterCompile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the after compile flag.
|
||||
/// </summary>
|
||||
public static void SetIsAfterCompile(bool isAfterCompile)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { isAfterCompile = isAfterCompile };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the domain reload in progress flag.
|
||||
/// </summary>
|
||||
public static bool GetIsDomainReloadInProgress()
|
||||
{
|
||||
return GetSettings().isDomainReloadInProgress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the domain reload in progress flag.
|
||||
/// </summary>
|
||||
public static void SetIsDomainReloadInProgress(bool isDomainReloadInProgress)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { isDomainReloadInProgress = isDomainReloadInProgress };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reconnecting flag.
|
||||
/// </summary>
|
||||
public static bool GetIsReconnecting()
|
||||
{
|
||||
return GetSettings().isReconnecting;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the reconnecting flag.
|
||||
/// </summary>
|
||||
public static void SetIsReconnecting(bool isReconnecting)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { isReconnecting = isReconnecting };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the show reconnecting UI flag.
|
||||
/// </summary>
|
||||
public static bool GetShowReconnectingUI()
|
||||
{
|
||||
return GetSettings().showReconnectingUI;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the show reconnecting UI flag.
|
||||
/// </summary>
|
||||
public static void SetShowReconnectingUI(bool showReconnectingUI)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { showReconnectingUI = showReconnectingUI };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the show post compile reconnecting UI flag.
|
||||
/// </summary>
|
||||
public static bool GetShowPostCompileReconnectingUI()
|
||||
{
|
||||
return GetSettings().showPostCompileReconnectingUI;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the show post compile reconnecting UI flag.
|
||||
/// </summary>
|
||||
public static void SetShowPostCompileReconnectingUI(bool showPostCompileReconnectingUI)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { showPostCompileReconnectingUI = showPostCompileReconnectingUI };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the selected editor type.
|
||||
/// </summary>
|
||||
public static McpEditorType GetSelectedEditorType()
|
||||
{
|
||||
return (McpEditorType)GetSettings().selectedEditorType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the selected editor type.
|
||||
/// </summary>
|
||||
public static void SetSelectedEditorType(McpEditorType selectedEditorType)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { selectedEditorType = (int)selectedEditorType };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
public static ConnectionMode GetConnectionMode()
|
||||
{
|
||||
return (ConnectionMode)GetSettings().connectionMode;
|
||||
}
|
||||
|
||||
public static void SetConnectionMode(ConnectionMode mode)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { connectionMode = (int)mode };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the communication log height.
|
||||
/// </summary>
|
||||
public static float GetCommunicationLogHeight()
|
||||
{
|
||||
return GetSettings().communicationLogHeight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the communication log height.
|
||||
/// </summary>
|
||||
public static void SetCommunicationLogHeight(float communicationLogHeight)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { communicationLogHeight = communicationLogHeight };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the communication logs JSON.
|
||||
/// </summary>
|
||||
public static string GetCommunicationLogsJson()
|
||||
{
|
||||
return GetSettings().communicationLogsJson;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the communication logs JSON.
|
||||
/// </summary>
|
||||
public static void SetCommunicationLogsJson(string communicationLogsJson)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { communicationLogsJson = communicationLogsJson };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pending requests JSON.
|
||||
/// </summary>
|
||||
public static string GetPendingRequestsJson()
|
||||
{
|
||||
return GetSettings().pendingRequestsJson;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the pending requests JSON.
|
||||
/// </summary>
|
||||
public static void SetPendingRequestsJson(string pendingRequestsJson)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { pendingRequestsJson = pendingRequestsJson };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets the compile window has data flag.
|
||||
/// </summary>
|
||||
public static bool GetCompileWindowHasData()
|
||||
{
|
||||
return GetSettings().compileWindowHasData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the compile window has data flag.
|
||||
/// </summary>
|
||||
public static void SetCompileWindowHasData(bool compileWindowHasData)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { compileWindowHasData = compileWindowHasData };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
// Helper methods that mirror McpSessionManager functionality
|
||||
|
||||
/// <summary>
|
||||
/// Clear server session.
|
||||
/// </summary>
|
||||
public static void ClearServerSession()
|
||||
{
|
||||
UpdateSettings(settings => settings with
|
||||
{
|
||||
isServerRunning = false,
|
||||
serverSessionId = string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear after compile flag.
|
||||
/// </summary>
|
||||
public static void ClearAfterCompileFlag()
|
||||
{
|
||||
SetIsAfterCompile(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear reconnecting flags.
|
||||
/// </summary>
|
||||
public static void ClearReconnectingFlags()
|
||||
{
|
||||
UpdateSettings(s => s with
|
||||
{
|
||||
isReconnecting = false,
|
||||
showReconnectingUI = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear post compile reconnecting UI.
|
||||
/// </summary>
|
||||
public static void ClearPostCompileReconnectingUI()
|
||||
{
|
||||
SetShowPostCompileReconnectingUI(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear domain reload flag.
|
||||
/// </summary>
|
||||
public static void ClearDomainReloadFlag()
|
||||
{
|
||||
SetIsDomainReloadInProgress(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear communication logs.
|
||||
/// </summary>
|
||||
public static void ClearCommunicationLogs()
|
||||
{
|
||||
UpdateSettings(s => s with
|
||||
{
|
||||
communicationLogsJson = "[]",
|
||||
pendingRequestsJson = "{}"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear compile window data.
|
||||
/// </summary>
|
||||
public static void ClearCompileWindowData()
|
||||
{
|
||||
SetCompileWindowHasData(false);
|
||||
}
|
||||
|
||||
// Compile request management methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pending compile request IDs.
|
||||
/// </summary>
|
||||
public static string[] GetPendingCompileRequestIds()
|
||||
{
|
||||
return GetSettings().pendingCompileRequestIds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the pending compile request IDs.
|
||||
/// </summary>
|
||||
public static void SetPendingCompileRequestIds(string[] pendingCompileRequestIds)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { pendingCompileRequestIds = pendingCompileRequestIds };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the compile requests.
|
||||
/// </summary>
|
||||
public static CompileRequestData[] GetCompileRequests()
|
||||
{
|
||||
return GetSettings().compileRequests;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the compile requests.
|
||||
/// </summary>
|
||||
public static void SetCompileRequests(CompileRequestData[] compileRequests)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { compileRequests = compileRequests };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the compile request JSON by request ID.
|
||||
/// </summary>
|
||||
public static string GetCompileRequestJson(string requestId)
|
||||
{
|
||||
CompileRequestData[] requests = GetCompileRequests();
|
||||
CompileRequestData request = System.Array.Find(requests, r => r.requestId == requestId);
|
||||
return request?.json;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the compile request JSON for a specific request ID.
|
||||
/// </summary>
|
||||
public static void SetCompileRequestJson(string requestId, string json)
|
||||
{
|
||||
CompileRequestData[] requests = GetCompileRequests();
|
||||
CompileRequestData existingRequest = System.Array.Find(requests, r => r.requestId == requestId);
|
||||
|
||||
if (existingRequest != null)
|
||||
{
|
||||
existingRequest.json = json;
|
||||
}
|
||||
else
|
||||
{
|
||||
CompileRequestData[] newRequests = new CompileRequestData[requests.Length + 1];
|
||||
System.Array.Copy(requests, newRequests, requests.Length);
|
||||
newRequests[requests.Length] = new CompileRequestData { requestId = requestId, json = json };
|
||||
requests = newRequests;
|
||||
}
|
||||
|
||||
SetCompileRequests(requests);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all compile requests.
|
||||
/// </summary>
|
||||
public static void ClearAllCompileRequests()
|
||||
{
|
||||
SetCompileRequests(new CompileRequestData[0]);
|
||||
SetPendingCompileRequestIds(new string[0]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a pending compile request.
|
||||
/// </summary>
|
||||
public static void AddPendingCompileRequest(string requestId)
|
||||
{
|
||||
string[] pendingIds = GetPendingCompileRequestIds();
|
||||
if (System.Array.IndexOf(pendingIds, requestId) == -1)
|
||||
{
|
||||
string[] newPendingIds = new string[pendingIds.Length + 1];
|
||||
System.Array.Copy(pendingIds, newPendingIds, pendingIds.Length);
|
||||
newPendingIds[pendingIds.Length] = requestId;
|
||||
SetPendingCompileRequestIds(newPendingIds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a pending compile request.
|
||||
/// </summary>
|
||||
public static void RemovePendingCompileRequest(string requestId)
|
||||
{
|
||||
string[] pendingIds = GetPendingCompileRequestIds();
|
||||
int index = System.Array.IndexOf(pendingIds, requestId);
|
||||
if (index != -1)
|
||||
{
|
||||
string[] newPendingIds = new string[pendingIds.Length - 1];
|
||||
System.Array.Copy(pendingIds, 0, newPendingIds, 0, index);
|
||||
System.Array.Copy(pendingIds, index + 1, newPendingIds, index, pendingIds.Length - index - 1);
|
||||
SetPendingCompileRequestIds(newPendingIds);
|
||||
}
|
||||
}
|
||||
|
||||
// Connected LLM Tools management methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connected LLM tools.
|
||||
/// </summary>
|
||||
public static ConnectedLLMToolData[] GetConnectedLLMTools()
|
||||
{
|
||||
return GetSettings().connectedLLMTools;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the connected LLM tools.
|
||||
/// </summary>
|
||||
public static void SetConnectedLLMTools(ConnectedLLMToolData[] connectedLLMTools)
|
||||
{
|
||||
McpEditorSettingsData settings = GetSettings();
|
||||
McpEditorSettingsData newSettings = settings with { connectedLLMTools = connectedLLMTools };
|
||||
SaveSettings(newSettings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a connected LLM tool.
|
||||
/// </summary>
|
||||
public static void AddConnectedLLMTool(ConnectedLLMToolData toolData)
|
||||
{
|
||||
if (toolData == null || string.IsNullOrEmpty(toolData.Name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectedLLMToolData[] tools = GetConnectedLLMTools();
|
||||
|
||||
// Remove existing tool with same name if present
|
||||
ConnectedLLMToolData[] filteredTools = System.Array.FindAll(tools, t => t.Name != toolData.Name);
|
||||
|
||||
// Add new tool
|
||||
ConnectedLLMToolData[] newTools = new ConnectedLLMToolData[filteredTools.Length + 1];
|
||||
System.Array.Copy(filteredTools, newTools, filteredTools.Length);
|
||||
newTools[filteredTools.Length] = toolData;
|
||||
|
||||
SetConnectedLLMTools(newTools);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a connected LLM tool by name.
|
||||
/// </summary>
|
||||
public static void RemoveConnectedLLMTool(string toolName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(toolName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectedLLMToolData[] tools = GetConnectedLLMTools();
|
||||
ConnectedLLMToolData[] newTools = System.Array.FindAll(tools, t => t.Name != toolName);
|
||||
|
||||
if (newTools.Length != tools.Length)
|
||||
{
|
||||
SetConnectedLLMTools(newTools);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all connected LLM tools.
|
||||
/// </summary>
|
||||
public static void ClearConnectedLLMTools()
|
||||
{
|
||||
SetConnectedLLMTools(new ConnectedLLMToolData[0]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the settings file.
|
||||
/// </summary>
|
||||
private static void LoadSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Security: Validate settings file path
|
||||
if (!IsValidSettingsPath(SettingsFilePath))
|
||||
{
|
||||
throw new SecurityException($"Invalid settings file path: {SettingsFilePath}");
|
||||
}
|
||||
|
||||
RecoverSettingsFileIfNeeded();
|
||||
|
||||
if (File.Exists(SettingsFilePath))
|
||||
{
|
||||
// Security: Check file size before reading
|
||||
FileInfo fileInfo = new FileInfo(SettingsFilePath);
|
||||
if (fileInfo.Length > McpConstants.MAX_SETTINGS_SIZE_BYTES)
|
||||
{
|
||||
throw new SecurityException("Settings file exceeds size limit");
|
||||
}
|
||||
|
||||
string json = File.ReadAllText(SettingsFilePath);
|
||||
|
||||
// Security: Validate JSON content
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
throw new InvalidDataException("Settings file contains invalid JSON content");
|
||||
}
|
||||
|
||||
_cachedSettings = JsonUtility.FromJson<McpEditorSettingsData>(json);
|
||||
|
||||
// Migrate security fields before any potential SaveSettings call from this class.
|
||||
// If SaveSettings runs first, legacy security fields are stripped from JSON
|
||||
// because McpEditorSettingsData no longer defines them.
|
||||
ULoopSettings.GetSettings();
|
||||
}
|
||||
else
|
||||
{
|
||||
_cachedSettings = new McpEditorSettingsData();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't suppress this exception - corrupted settings should be reported
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to load MCP Editor settings from: {SettingsFilePath}. Settings file may be corrupted.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeProjectRootPath(string projectRootPath)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(projectRootPath);
|
||||
return fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Security: Validate if the settings file path is safe
|
||||
/// </summary>
|
||||
private static bool IsValidSettingsPath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Normalize the path to prevent path traversal
|
||||
string normalizedPath = Path.GetFullPath(path);
|
||||
|
||||
// Must be under UserSettings directory
|
||||
string expectedUserSettingsPath = Path.GetFullPath(McpConstants.USER_SETTINGS_FOLDER);
|
||||
|
||||
// Check if path is within the expected directory
|
||||
return normalizedPath.StartsWith(expectedUserSettingsPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
normalizedPath.EndsWith(McpConstants.SETTINGS_FILE_NAME, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogError($"{McpConstants.SECURITY_LOG_PREFIX} Error validating settings path {path}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a89ab75dad5644d9d897cf88a1984689
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically updates MCP configuration files when port number changes
|
||||
///
|
||||
/// Design document reference: Packages/src/Editor/ARCHITECTURE.md
|
||||
///
|
||||
/// Related classes:
|
||||
/// - McpEditorModel: Calls this class when UI port changes
|
||||
/// - McpServerController: Calls this class when server port changes due to conflicts
|
||||
/// - McpConfigService: Used to update individual editor configurations
|
||||
/// - McpConfigServiceFactory: Creates configuration services for different editors
|
||||
/// </summary>
|
||||
public static class McpPortChangeUpdater
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates all configured MCP editor settings with new port number
|
||||
/// Called when port number changes in UI or due to server port conflicts
|
||||
/// </summary>
|
||||
/// <param name="newPort">The new port number to update to</param>
|
||||
/// <param name="reason">Reason for the port change (for logging)</param>
|
||||
public static void UpdateAllConfigurationsForPortChange(int newPort, string reason)
|
||||
{
|
||||
string correlationId = VibeLogger.GenerateCorrelationId();
|
||||
|
||||
VibeLogger.LogInfo(
|
||||
"port_change_update_start",
|
||||
$"Starting MCP configuration update for port change: {newPort}",
|
||||
new { new_port = newPort, reason = reason },
|
||||
correlationId,
|
||||
$"Port change update triggered: {reason}",
|
||||
"Monitor which editor configurations are updated with new port"
|
||||
);
|
||||
|
||||
int updatedCount = 0;
|
||||
McpConfigServiceFactory factory = new();
|
||||
|
||||
// Check current debug mode
|
||||
bool isDebugMode;
|
||||
#if ULOOPMCP_DEBUG
|
||||
isDebugMode = true;
|
||||
#else
|
||||
isDebugMode = false;
|
||||
#endif
|
||||
|
||||
// Check all editor types for existing configurations
|
||||
foreach (McpEditorType editorType in Enum.GetValues(typeof(McpEditorType)))
|
||||
{
|
||||
IMcpConfigService configService = factory.GetConfigService(editorType);
|
||||
|
||||
// Only update if configuration already exists
|
||||
if (configService.IsConfigured())
|
||||
{
|
||||
// Update the configuration with new port and current debug settings
|
||||
configService.UpdateDevelopmentSettings(newPort, false, isDebugMode);
|
||||
updatedCount++;
|
||||
|
||||
VibeLogger.LogInfo(
|
||||
"port_change_editor_updated",
|
||||
$"Updated {editorType} configuration with new port: {newPort}",
|
||||
new { editor_type = editorType.ToString(), new_port = newPort, debug_mode = isDebugMode, reason = reason },
|
||||
correlationId,
|
||||
$"{editorType} settings updated with new port",
|
||||
"Verify that port number is correctly updated in configuration file"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
VibeLogger.LogInfo(
|
||||
"port_change_update_complete",
|
||||
$"Completed MCP configuration port update. Updated {updatedCount} editor configurations with port {newPort}.",
|
||||
new { new_port = newPort, updated_count = updatedCount, reason = reason },
|
||||
correlationId,
|
||||
"Port change synchronization completed",
|
||||
"All configured editors should now have the updated port number"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e0fa39b2f287d4a02b4312554abb5961
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Class responsible for comparing MCP server settings.
|
||||
/// Single Responsibility Principle: Only responsible for comparing settings.
|
||||
/// </summary>
|
||||
public static class McpServerConfigComparer
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if two settings are equal.
|
||||
/// </summary>
|
||||
public static bool AreEqual(McpServerConfigData config1, McpServerConfigData config2)
|
||||
{
|
||||
// Compare command.
|
||||
if (config1.command != config2.command)
|
||||
return false;
|
||||
|
||||
// Compare args.
|
||||
if (config1.args.Length != config2.args.Length)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < config1.args.Length; i++)
|
||||
{
|
||||
if (config1.args[i] != config2.args[i])
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare env.
|
||||
if (config1.env.Count != config2.env.Count)
|
||||
return false;
|
||||
|
||||
foreach (KeyValuePair<string, string> kvp in config1.env)
|
||||
{
|
||||
if (!config2.env.ContainsKey(kvp.Key) || config2.env[kvp.Key] != kvp.Value)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e70bd0dd6ae54f968571002e64f2509
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Factory class for creating MCP server configuration objects.
|
||||
/// Single Responsibility Principle: Only responsible for object creation.
|
||||
///
|
||||
/// Design document reference: Packages/src/Editor/ARCHITECTURE.md
|
||||
///
|
||||
/// Related classes:
|
||||
/// - McpConfigService: Uses this factory to create server configurations
|
||||
/// - McpServerConfigData: Value object that this factory creates
|
||||
/// - UnityMcpPathResolver: Provides TypeScript server paths and project root
|
||||
/// - McpConstants: Contains command constants (NODE_COMMAND) and environment keys
|
||||
/// - McpEditorType: Enum defining supported editor types
|
||||
///
|
||||
/// Key features:
|
||||
/// - Creates server configuration with editor-specific path formats (absolute/relative)
|
||||
/// - Generates server keys with or without port numbers based on editor type
|
||||
/// - Environment variables are minimal (only UNITY_TCP_PORT, no MCP_CLIENT_NAME)
|
||||
/// </summary>
|
||||
public static class McpServerConfigFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates Unity MCP server configuration.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number to use.</param>
|
||||
/// <param name="serverPath">The path to the TypeScript server.</param>
|
||||
/// <param name="editorType">The editor type for client name.</param>
|
||||
/// <returns>Settings data for Unity MCP.</returns>
|
||||
public static McpServerConfigData CreateUnityMcpConfig(int port, string serverPath, McpEditorType editorType)
|
||||
{
|
||||
// Convert server path to appropriate format based on editor type
|
||||
string finalServerPath = GetServerPathForEditor(serverPath, editorType);
|
||||
|
||||
Dictionary<string, string> env = new Dictionary<string, string>
|
||||
{
|
||||
{ McpConstants.UNITY_TCP_PORT_ENV_KEY, port.ToString() }
|
||||
// MCP_CLIENT_NAME removed - now using clientInfo.name from MCP protocol
|
||||
#if ULOOPMCP_DEBUG
|
||||
, { McpConstants.ENV_KEY_MCP_DEBUG, McpConstants.ENV_VALUE_TRUE }
|
||||
, { McpConstants.ENV_KEY_NODE_OPTIONS, McpConstants.NODE_OPTIONS_ENABLE_SOURCE_MAPS }
|
||||
#endif
|
||||
};
|
||||
|
||||
if (!NodePathResolver.IsNodeAvailable())
|
||||
{
|
||||
throw new System.InvalidOperationException(
|
||||
"Node.js is not available. Ensure Node.js is installed and accessible via PATH.");
|
||||
}
|
||||
|
||||
return new McpServerConfigData(
|
||||
command: McpConstants.NODE_COMMAND,
|
||||
args: new[] { finalServerPath },
|
||||
env: env
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get appropriate server path based on editor type
|
||||
/// </summary>
|
||||
/// <param name="serverPath">The original server path</param>
|
||||
/// <param name="editorType">The editor type</param>
|
||||
/// <returns>The processed server path</returns>
|
||||
private static string GetServerPathForEditor(string serverPath, McpEditorType editorType)
|
||||
{
|
||||
// Desktop editors (Cursor, VSCode, Windsurf) require absolute path for proper connection
|
||||
if (editorType == McpEditorType.Cursor ||
|
||||
editorType == McpEditorType.VSCode ||
|
||||
editorType == McpEditorType.Windsurf)
|
||||
{
|
||||
return serverPath;
|
||||
}
|
||||
|
||||
// CLI / project-level editors (Claude Code, Gemini CLI, Codex) use relative path for better portability
|
||||
return ConvertToRelativePath(serverPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert absolute path to relative path for all editors
|
||||
/// </summary>
|
||||
/// <param name="absolutePath">The absolute path to convert</param>
|
||||
/// <returns>The relative path from project root</returns>
|
||||
private static string ConvertToRelativePath(string absolutePath)
|
||||
{
|
||||
return UnityMcpPathResolver.MakeRelativeToConfigurationRoot(absolutePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates Unity MCP server configuration (legacy overload for backward compatibility).
|
||||
/// </summary>
|
||||
/// <param name="port">The port number to use.</param>
|
||||
/// <param name="serverPath">The path to the TypeScript server.</param>
|
||||
/// <returns>Settings data for Unity MCP.</returns>
|
||||
public static McpServerConfigData CreateUnityMcpConfig(int port, string serverPath)
|
||||
{
|
||||
return CreateUnityMcpConfig(port, serverPath, McpEditorType.ClaudeCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates Unity MCP server configuration with development mode support.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number.</param>
|
||||
/// <param name="serverPath">The path to the TypeScript server.</param>
|
||||
/// <param name="developmentMode">Whether to enable development mode.</param>
|
||||
/// <returns>Settings data for Unity MCP.</returns>
|
||||
public static McpServerConfigData CreateUnityMcpConfigWithDevelopmentMode(int port, string serverPath, bool developmentMode)
|
||||
{
|
||||
Dictionary<string, string> env = new Dictionary<string, string>
|
||||
{
|
||||
{ McpConstants.UNITY_TCP_PORT_ENV_KEY, port.ToString() }
|
||||
#if ULOOPMCP_DEBUG
|
||||
, { McpConstants.ENV_KEY_MCP_DEBUG, McpConstants.ENV_VALUE_TRUE }
|
||||
#endif
|
||||
};
|
||||
|
||||
if (!NodePathResolver.IsNodeAvailable())
|
||||
{
|
||||
throw new System.InvalidOperationException(
|
||||
"Node.js is not available. Ensure Node.js is installed and accessible via PATH.");
|
||||
}
|
||||
|
||||
return new McpServerConfigData(
|
||||
command: McpConstants.NODE_COMMAND,
|
||||
args: new[] { serverPath },
|
||||
env: env
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates Unity MCP server key.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number.</param>
|
||||
/// <returns>The server key.</returns>
|
||||
public static string CreateUnityMcpServerKey(int port)
|
||||
{
|
||||
return $"{McpConstants.PROJECT_NAME}-{port}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates Unity MCP server key with editor type consideration.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number.</param>
|
||||
/// <param name="editorType">The editor type.</param>
|
||||
/// <returns>The server key.</returns>
|
||||
public static string CreateUnityMcpServerKey(int port, McpEditorType editorType)
|
||||
{
|
||||
// Windsurf keeps the port number in the key
|
||||
if (editorType == McpEditorType.Windsurf)
|
||||
{
|
||||
return $"{McpConstants.PROJECT_NAME}-{port}";
|
||||
}
|
||||
// Other editors use simple key without port
|
||||
return McpConstants.PROJECT_NAME;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 93ef9e15f9e684807b7e8878b0cc6e3b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,37 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Unity MCP Editor UI constants
|
||||
/// Centralized management of UI-related constants
|
||||
/// </summary>
|
||||
public static class McpUIConstants
|
||||
{
|
||||
// Communication log area
|
||||
public const float DEFAULT_COMMUNICATION_LOG_HEIGHT = 300f;
|
||||
|
||||
// UI spacing and dimensions
|
||||
public const float BUTTON_HEIGHT_LARGE = 30f;
|
||||
|
||||
// UI colors
|
||||
private const float SECTION_BACKGROUND_COLOR_ONE = 0.18f;
|
||||
public static readonly Color SECTION_BACKGROUND_COLOR = new(SECTION_BACKGROUND_COLOR_ONE, SECTION_BACKGROUND_COLOR_ONE, SECTION_BACKGROUND_COLOR_ONE, 1f);
|
||||
|
||||
private const float CLIENT_ITEM_BACKGROUND_COLOR_ONE = 0.3f;
|
||||
public static readonly Color CLIENT_ITEM_BACKGROUND_COLOR = new(CLIENT_ITEM_BACKGROUND_COLOR_ONE, CLIENT_ITEM_BACKGROUND_COLOR_ONE, CLIENT_ITEM_BACKGROUND_COLOR_ONE, 0.5f);
|
||||
|
||||
// Communication log settings
|
||||
public const int MAX_COMMUNICATION_LOG_ENTRIES = 20;
|
||||
|
||||
// Connected clients display
|
||||
public const float CLIENT_ITEM_SPACING = 3f;
|
||||
public const string CONNECTED_TOOLS_FOLDOUT_TEXT = "Connected LLM Tools";
|
||||
public const string CLIENT_ICON = "● ";
|
||||
|
||||
// Tool Settings
|
||||
public const string TOOL_SETTINGS_MENU_PATH = "Window > uLoop > Tool Settings";
|
||||
public const string CLI_COMMAND_REFERENCE_URL = "https://github.com/hatayama/uLoopMCP/blob/main/Packages/src/Cli~/README.md#cli-command-reference";
|
||||
public const string PROJECT_REPOSITORY_URL = "https://github.com/hatayama/unity-cli-loop";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 20c01e8033581448c877121b760d2fed
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
public static class McpVersion
|
||||
{
|
||||
public const string VERSION = "2.2.0"; // x-release-please-version
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: db3c2fe880474407a80706968e49f262
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Copies server.bundle.js to a fixed location in Library folder.
|
||||
/// This prevents mcp.json from needing updates when uLoopMCP package version changes.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class ServerBundleCopier
|
||||
{
|
||||
static ServerBundleCopier()
|
||||
{
|
||||
EnsureServerBundleCopied();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures server.bundle.js is copied to the fixed Library path.
|
||||
/// Called automatically on Unity startup and can be called manually if needed.
|
||||
/// </summary>
|
||||
public static void EnsureServerBundleCopied()
|
||||
{
|
||||
string sourcePath = GetSourceServerBundlePath();
|
||||
if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string destPath = GetFixedServerBundlePath();
|
||||
CopyServerBundleWhenChanged(sourcePath, destPath);
|
||||
}
|
||||
|
||||
internal static bool CopyServerBundleWhenChanged(string sourcePath, string destinationPath)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrWhiteSpace(sourcePath), "sourcePath must not be empty");
|
||||
Debug.Assert(!string.IsNullOrWhiteSpace(destinationPath), "destinationPath must not be empty");
|
||||
Debug.Assert(File.Exists(sourcePath), "sourcePath must exist");
|
||||
|
||||
string destDir = Path.GetDirectoryName(destinationPath);
|
||||
|
||||
if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir))
|
||||
{
|
||||
Directory.CreateDirectory(destDir);
|
||||
}
|
||||
|
||||
File.Copy(sourcePath, destinationPath, overwrite: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fixed server bundle path in Library folder.
|
||||
/// </summary>
|
||||
public static string GetFixedServerBundlePath()
|
||||
{
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
return Path.Combine(
|
||||
projectRoot,
|
||||
McpConstants.LIBRARY_DIR,
|
||||
McpConstants.ULOOPMCP_DIR,
|
||||
McpConstants.SERVER_BUNDLE_FILE
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source path of server.bundle.js from local development, submodule, or PackageCache.
|
||||
/// </summary>
|
||||
private static string GetSourceServerBundlePath()
|
||||
{
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
|
||||
// 1. Check local development path (Packages/src)
|
||||
string localPath = Path.Combine(
|
||||
projectRoot,
|
||||
McpConstants.PACKAGES_DIR,
|
||||
McpConstants.SRC_DIR,
|
||||
McpConstants.TYPESCRIPT_SERVER_DIR,
|
||||
McpConstants.DIST_DIR,
|
||||
McpConstants.SERVER_BUNDLE_FILE
|
||||
);
|
||||
if (File.Exists(localPath))
|
||||
{
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// 2. Check package path via Unity Package Manager API (supports submodules)
|
||||
string packagePath = McpConstants.PackageResolvedPath;
|
||||
if (!string.IsNullOrEmpty(packagePath))
|
||||
{
|
||||
string serverPath = Path.Combine(
|
||||
packagePath,
|
||||
McpConstants.TYPESCRIPT_SERVER_DIR,
|
||||
McpConstants.DIST_DIR,
|
||||
McpConstants.SERVER_BUNDLE_FILE
|
||||
);
|
||||
if (File.Exists(serverPath))
|
||||
{
|
||||
return serverPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Search in PackageCache
|
||||
string packageCacheDir = Path.Combine(
|
||||
projectRoot,
|
||||
McpConstants.LIBRARY_DIR,
|
||||
McpConstants.PACKAGE_CACHE_DIR
|
||||
);
|
||||
if (Directory.Exists(packageCacheDir))
|
||||
{
|
||||
string[] packageDirs = Directory.GetDirectories(packageCacheDir, McpConstants.PackageNamePattern);
|
||||
foreach (string packageDir in packageDirs)
|
||||
{
|
||||
string serverPath = Path.Combine(
|
||||
packageDir,
|
||||
McpConstants.TYPESCRIPT_SERVER_DIR,
|
||||
McpConstants.DIST_DIR,
|
||||
McpConstants.SERVER_BUNDLE_FILE
|
||||
);
|
||||
if (File.Exists(serverPath))
|
||||
{
|
||||
return serverPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c100d0e898aa4a23b017cf167b167f0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,806 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
internal static class SkillInstallLayout
|
||||
{
|
||||
internal const string SkillsDirName = "skills";
|
||||
internal const string ManagedSkillsDirName = "unity-cli-loop";
|
||||
internal const string SkillFileName = "SKILL.md";
|
||||
private const string CliPackageDirName = "Cli~";
|
||||
private const string CliSkillDefinitionsDirName = "skill-definitions";
|
||||
private const string CliOnlySkillDefinitionsDirName = "cli-only";
|
||||
private const string MarkdownFileExtension = ".md";
|
||||
private static readonly HashSet<string> ExcludedFileNames = new()
|
||||
{
|
||||
".meta",
|
||||
".DS_Store",
|
||||
".gitkeep"
|
||||
};
|
||||
private static readonly Regex UloopCommandPattern = new(
|
||||
"(^|[^A-Za-z0-9_-])uloop(?=\\s)",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private sealed class SkillSourceDefinition
|
||||
{
|
||||
public readonly string Name;
|
||||
public readonly string ToolName;
|
||||
public readonly string SkillDirectoryPath;
|
||||
public readonly Dictionary<string, byte[]> SkillFiles;
|
||||
|
||||
public SkillSourceDefinition(
|
||||
string name,
|
||||
string toolName,
|
||||
string skillDirectoryPath,
|
||||
Dictionary<string, byte[]> skillFiles)
|
||||
{
|
||||
Name = name;
|
||||
ToolName = toolName;
|
||||
SkillDirectoryPath = skillDirectoryPath;
|
||||
SkillFiles = skillFiles;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly struct SkillSourceInfo
|
||||
{
|
||||
public readonly string Name;
|
||||
public readonly string ToolName;
|
||||
public readonly Dictionary<string, byte[]> SkillFiles;
|
||||
|
||||
public SkillSourceInfo(
|
||||
string name,
|
||||
string toolName,
|
||||
Dictionary<string, byte[]> skillFiles)
|
||||
{
|
||||
Name = name;
|
||||
ToolName = toolName;
|
||||
SkillFiles = skillFiles;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string GetSkillsRoot(string targetRoot)
|
||||
{
|
||||
return Path.Combine(targetRoot, SkillsDirName);
|
||||
}
|
||||
|
||||
internal static string GetManagedSkillsRoot(string targetRoot)
|
||||
{
|
||||
return Path.Combine(GetSkillsRoot(targetRoot), ManagedSkillsDirName);
|
||||
}
|
||||
|
||||
internal static bool HasOptedInSkillsDirectory(string targetRoot)
|
||||
{
|
||||
return Directory.Exists(GetSkillsRoot(targetRoot));
|
||||
}
|
||||
|
||||
internal static IEnumerable<string> EnumerateInstalledSkillDirectories(string targetRoot)
|
||||
{
|
||||
foreach (string skillDir in EnumerateManagedSkillDirectories(targetRoot))
|
||||
{
|
||||
yield return skillDir;
|
||||
}
|
||||
|
||||
foreach (string skillDir in EnumerateLegacyManagedSkillDirectories(targetRoot))
|
||||
{
|
||||
yield return skillDir;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool HasInstalledSkills(string targetRoot)
|
||||
{
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
return HasInstalledSkills(projectRoot, targetRoot);
|
||||
}
|
||||
|
||||
internal static bool HasInstalledSkills(string targetRoot, bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
return HasInstalledSkills(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
internal static bool HasInstalledSkills(string projectRoot, string targetRoot)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
Debug.Assert(!string.IsNullOrEmpty(targetRoot), "targetRoot must not be null or empty");
|
||||
|
||||
return HasInstalledSkills(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop: false)
|
||||
|| HasInstalledSkills(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop: true);
|
||||
}
|
||||
|
||||
internal static bool HasInstalledSkills(
|
||||
string projectRoot,
|
||||
string targetRoot,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
Debug.Assert(!string.IsNullOrEmpty(targetRoot), "targetRoot must not be null or empty");
|
||||
|
||||
if (!groupSkillsUnderUnityCliLoop && EnumerateLegacyManagedSkillDirectories(targetRoot).Any())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Dictionary<string, SkillSourceDefinition> expectedSkills = GetSkillSources(projectRoot);
|
||||
if (expectedSkills.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return expectedSkills.Keys.Any(skillName =>
|
||||
Directory.Exists(GetInstalledSkillDirectoryPath(targetRoot, skillName, groupSkillsUnderUnityCliLoop)));
|
||||
}
|
||||
|
||||
internal static SkillInstallState GetInstalledState(
|
||||
string projectRoot,
|
||||
string targetRoot,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
Dictionary<string, SkillSourceDefinition> expectedSkills = GetSkillSources(projectRoot);
|
||||
bool hasLayoutSkills = HasInstalledSkills(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop);
|
||||
if (expectedSkills.Count == 0)
|
||||
{
|
||||
return hasLayoutSkills ? SkillInstallState.Installed : SkillInstallState.Missing;
|
||||
}
|
||||
|
||||
bool hasInstalledExpectedSkill = false;
|
||||
bool hasMissingExpectedSkill = false;
|
||||
|
||||
foreach (SkillSourceDefinition expectedSkill in expectedSkills.Values)
|
||||
{
|
||||
string installedSkillDirectory = GetInstalledSkillDirectoryPath(
|
||||
targetRoot,
|
||||
expectedSkill.Name,
|
||||
groupSkillsUnderUnityCliLoop);
|
||||
if (!Directory.Exists(installedSkillDirectory))
|
||||
{
|
||||
hasMissingExpectedSkill = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
hasInstalledExpectedSkill = true;
|
||||
if (IsSkillDirectoryOutdated(expectedSkill.SkillFiles, installedSkillDirectory))
|
||||
{
|
||||
return SkillInstallState.Outdated;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasInstalledExpectedSkill)
|
||||
{
|
||||
return hasLayoutSkills ? SkillInstallState.Outdated : SkillInstallState.Missing;
|
||||
}
|
||||
|
||||
return hasMissingExpectedSkill ? SkillInstallState.Outdated : SkillInstallState.Installed;
|
||||
}
|
||||
|
||||
internal static bool SkillMatchesTool(string skillDir, string toolName)
|
||||
{
|
||||
string skillMdPath = Path.Combine(skillDir, SkillFileName);
|
||||
if (File.Exists(skillMdPath))
|
||||
{
|
||||
string content = File.ReadAllText(skillMdPath);
|
||||
string parsed = ParseToolNameFromFrontmatter(content);
|
||||
if (!string.IsNullOrEmpty(parsed))
|
||||
{
|
||||
return parsed == toolName;
|
||||
}
|
||||
}
|
||||
|
||||
string dirName = Path.GetFileName(skillDir);
|
||||
return dirName == $"{CliConstants.SKILL_DIR_PREFIX}{toolName}";
|
||||
}
|
||||
|
||||
internal static List<SkillSourceInfo> GetSkillSourceInfos(string projectRoot)
|
||||
{
|
||||
return GetSkillSources(projectRoot)
|
||||
.Values
|
||||
.Select(source => new SkillSourceInfo(source.Name, source.ToolName, source.SkillFiles))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
internal static string GetInstalledSkillDirectoryPathForLayout(
|
||||
string targetRoot,
|
||||
string skillName,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
return GetInstalledSkillDirectoryPath(targetRoot, skillName, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
internal static IEnumerable<string> EnumerateInstalledSkillDirectoryNamesForLayout(
|
||||
string targetRoot,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
IEnumerable<string> installedSkillDirectories = groupSkillsUnderUnityCliLoop
|
||||
? EnumerateManagedSkillDirectories(targetRoot)
|
||||
: EnumerateLegacyManagedSkillDirectories(targetRoot);
|
||||
return installedSkillDirectories
|
||||
.Select(Path.GetFileName)
|
||||
.Where(name => !string.IsNullOrEmpty(name));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateManagedSkillDirectories(string targetRoot)
|
||||
{
|
||||
string managedSkillsRoot = GetManagedSkillsRoot(targetRoot);
|
||||
if (!Directory.Exists(managedSkillsRoot))
|
||||
{
|
||||
return Enumerable.Empty<string>();
|
||||
}
|
||||
|
||||
return Directory.EnumerateDirectories(managedSkillsRoot);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateLegacyManagedSkillDirectories(string targetRoot)
|
||||
{
|
||||
string skillsRoot = GetSkillsRoot(targetRoot);
|
||||
if (!Directory.Exists(skillsRoot))
|
||||
{
|
||||
return Enumerable.Empty<string>();
|
||||
}
|
||||
|
||||
return Directory.EnumerateDirectories(skillsRoot)
|
||||
.Where(skillDir => Path.GetFileName(skillDir) != ManagedSkillsDirName)
|
||||
.Where(IsLegacyManagedSkillDirectory);
|
||||
}
|
||||
|
||||
private static bool IsLegacyManagedSkillDirectory(string skillDir)
|
||||
{
|
||||
string dirName = Path.GetFileName(skillDir);
|
||||
if (dirName.StartsWith(CliConstants.SKILL_DIR_PREFIX, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string skillMdPath = Path.Combine(skillDir, SkillFileName);
|
||||
if (!File.Exists(skillMdPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string content = File.ReadAllText(skillMdPath);
|
||||
if (!string.IsNullOrEmpty(ParseToolNameFromFrontmatter(content)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetInstalledSkillDirectoryPath(
|
||||
string targetRoot,
|
||||
string skillName,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
Debug.Assert(IsSafeSkillPathComponent(skillName), "skillName must be a single safe path component");
|
||||
|
||||
string skillsRoot = groupSkillsUnderUnityCliLoop
|
||||
? GetManagedSkillsRoot(targetRoot)
|
||||
: GetSkillsRoot(targetRoot);
|
||||
return Path.Combine(skillsRoot, skillName);
|
||||
}
|
||||
|
||||
private static bool IsSkillDirectoryOutdated(
|
||||
Dictionary<string, byte[]> sourceFiles,
|
||||
string installedSkillDirectory)
|
||||
{
|
||||
Dictionary<string, byte[]> installedFiles = CollectInstalledSkillFiles(installedSkillDirectory);
|
||||
if (sourceFiles.Count != installedFiles.Count)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<string, byte[]> sourceFile in sourceFiles)
|
||||
{
|
||||
if (!installedFiles.TryGetValue(sourceFile.Key, out byte[] installedContent))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!sourceFile.Value.SequenceEqual(installedContent))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Dictionary<string, byte[]> CollectInstalledSkillFiles(string skillDirectory)
|
||||
{
|
||||
Dictionary<string, byte[]> files = new(StringComparer.Ordinal);
|
||||
foreach (string filePath in Directory.EnumerateFiles(skillDirectory, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string fileName = Path.GetFileName(filePath);
|
||||
if (IsExcludedFile(fileName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string relativePath = Path.GetRelativePath(skillDirectory, filePath);
|
||||
files[relativePath] = File.ReadAllBytes(filePath);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static Dictionary<string, byte[]> CollectSourceSkillFiles(
|
||||
string skillDirectory,
|
||||
string skillFilePath)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(skillDirectory), "skillDirectory must not be null or empty");
|
||||
Debug.Assert(!string.IsNullOrEmpty(skillFilePath), "skillFilePath must not be null or empty");
|
||||
|
||||
Dictionary<string, byte[]> sourceFiles;
|
||||
if (string.Equals(Path.GetFileName(skillDirectory), "Skill", StringComparison.Ordinal))
|
||||
{
|
||||
sourceFiles = CollectInstalledSkillFiles(skillDirectory);
|
||||
return FormatSkillFilesForCliInvocation(sourceFiles);
|
||||
}
|
||||
|
||||
sourceFiles = new Dictionary<string, byte[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[SkillFileName] = File.ReadAllBytes(skillFilePath)
|
||||
};
|
||||
return FormatSkillFilesForCliInvocation(sourceFiles);
|
||||
}
|
||||
|
||||
private static Dictionary<string, byte[]> FormatSkillFilesForCliInvocation(
|
||||
Dictionary<string, byte[]> skillFiles)
|
||||
{
|
||||
Debug.Assert(skillFiles != null, "skillFiles must not be null");
|
||||
|
||||
if (ToolSettings.GetSkillCliInvocation() == CliConstants.SKILL_CLI_INVOCATION_GLOBAL)
|
||||
{
|
||||
return skillFiles;
|
||||
}
|
||||
|
||||
Debug.Assert(skillFiles.ContainsKey(SkillFileName), "skillFiles must contain SKILL.md");
|
||||
foreach (string relativePath in skillFiles.Keys.ToArray())
|
||||
{
|
||||
if (!IsMarkdownSkillFile(relativePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string skillContent = Encoding.UTF8.GetString(skillFiles[relativePath]);
|
||||
string formattedContent = FormatSkillMarkdownForNpx(
|
||||
skillContent,
|
||||
McpConstants.PackageInfo.version);
|
||||
skillFiles[relativePath] = Encoding.UTF8.GetBytes(formattedContent);
|
||||
}
|
||||
return skillFiles;
|
||||
}
|
||||
|
||||
private static bool IsMarkdownSkillFile(string relativePath)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(relativePath), "relativePath must not be null or empty");
|
||||
return string.Equals(
|
||||
Path.GetExtension(relativePath),
|
||||
MarkdownFileExtension,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string FormatSkillMarkdownForNpx(string content, string packageVersion)
|
||||
{
|
||||
Debug.Assert(content != null, "content must not be null");
|
||||
Debug.Assert(!string.IsNullOrEmpty(packageVersion), "packageVersion must not be null or empty");
|
||||
|
||||
string npxCommand =
|
||||
$"{CliConstants.NPX_EXECUTABLE_NAME} {CliConstants.NPX_YES_FLAG} {CliConstants.NPM_PACKAGE_NAME}@{packageVersion}";
|
||||
Match frontmatterMatch = Regex.Match(
|
||||
content,
|
||||
"^(---\\r?\\n[\\s\\S]*?\\r?\\n---)(\\r?\\n?)([\\s\\S]*)$");
|
||||
if (!frontmatterMatch.Success)
|
||||
{
|
||||
return UloopCommandPattern.Replace(content, match => $"{match.Groups[1].Value}{npxCommand}");
|
||||
}
|
||||
|
||||
string frontmatter = $"{frontmatterMatch.Groups[1].Value}{frontmatterMatch.Groups[2].Value}";
|
||||
string body = frontmatterMatch.Groups[3].Value;
|
||||
string formattedBody = UloopCommandPattern.Replace(
|
||||
body,
|
||||
match => $"{match.Groups[1].Value}{npxCommand}");
|
||||
return $"{frontmatter}{formattedBody}";
|
||||
}
|
||||
|
||||
private static Dictionary<string, SkillSourceDefinition> GetSkillSources(string projectRoot)
|
||||
{
|
||||
Dictionary<string, SkillSourceDefinition> sources = new(StringComparer.Ordinal);
|
||||
foreach (string searchRoot in EnumerateSkillSourceRoots(projectRoot))
|
||||
{
|
||||
if (!Directory.Exists(searchRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IEnumerable<string> skillFilePaths = IsCliOnlySkillSourceRoot(searchRoot)
|
||||
? Directory.EnumerateFiles(searchRoot, SkillFileName, SearchOption.AllDirectories)
|
||||
: EnumerateEditorFolders(searchRoot, 3).SelectMany(editorFolder =>
|
||||
Directory.EnumerateFiles(editorFolder, SkillFileName, SearchOption.AllDirectories));
|
||||
foreach (string skillFilePath in skillFilePaths)
|
||||
{
|
||||
string skillDirectory = Path.GetDirectoryName(skillFilePath);
|
||||
if (skillDirectory == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string skillContent = File.ReadAllText(skillFilePath);
|
||||
if (IsInternalSkill(skillContent))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string skillName = ParseNameFromFrontmatter(skillContent);
|
||||
if (string.IsNullOrEmpty(skillName)
|
||||
|| !IsSafeSkillPathComponent(skillName)
|
||||
|| sources.ContainsKey(skillName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sources[skillName] = new SkillSourceDefinition(
|
||||
skillName,
|
||||
ParseToolNameFromFrontmatter(skillContent),
|
||||
skillDirectory,
|
||||
CollectSourceSkillFiles(skillDirectory, skillFilePath));
|
||||
}
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateSkillSourceRoots(string projectRoot)
|
||||
{
|
||||
HashSet<string> seenRoots = new(StringComparer.Ordinal);
|
||||
|
||||
AddSkillSourceRoot(seenRoots, GetCliOnlySkillSourceRoot(projectRoot));
|
||||
AddSkillSourceRoot(seenRoots, Path.Combine(projectRoot, "Assets"));
|
||||
foreach (string packageRoot in EnumerateDirectProjectPackageRoots(projectRoot))
|
||||
{
|
||||
AddSkillSourceRoot(seenRoots, packageRoot);
|
||||
}
|
||||
|
||||
foreach (string packageRoot in EnumerateManifestLocalPackageRoots(projectRoot))
|
||||
{
|
||||
AddSkillSourceRoot(seenRoots, packageRoot);
|
||||
}
|
||||
|
||||
foreach (string packageRoot in EnumerateDependencyPackageCacheRoots(projectRoot))
|
||||
{
|
||||
AddSkillSourceRoot(seenRoots, packageRoot);
|
||||
}
|
||||
|
||||
foreach (string root in seenRoots)
|
||||
{
|
||||
yield return root;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddSkillSourceRoot(HashSet<string> roots, string root)
|
||||
{
|
||||
if (string.IsNullOrEmpty(root))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
roots.Add(Path.GetFullPath(root));
|
||||
}
|
||||
|
||||
private static string GetCliOnlySkillSourceRoot(string projectRoot)
|
||||
{
|
||||
string currentProjectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
if (!string.Equals(
|
||||
Path.GetFullPath(projectRoot),
|
||||
Path.GetFullPath(currentProjectRoot),
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Path.Combine(
|
||||
McpConstants.PackageResolvedPath,
|
||||
CliPackageDirName,
|
||||
McpConstants.SRC_DIR,
|
||||
SkillsDirName,
|
||||
CliSkillDefinitionsDirName,
|
||||
CliOnlySkillDefinitionsDirName);
|
||||
}
|
||||
|
||||
private static bool IsCliOnlySkillSourceRoot(string searchRoot)
|
||||
{
|
||||
return string.Equals(
|
||||
Path.GetFullPath(searchRoot),
|
||||
Path.GetFullPath(GetCliOnlySkillSourceRoot(UnityMcpPathResolver.GetProjectRoot())),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateDirectProjectPackageRoots(string projectRoot)
|
||||
{
|
||||
string packagesRoot = Path.Combine(projectRoot, "Packages");
|
||||
if (!Directory.Exists(packagesRoot))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (string packageDirectory in Directory.EnumerateDirectories(packagesRoot))
|
||||
{
|
||||
yield return ResolveSkillSearchRootCandidate(packageDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateManifestLocalPackageRoots(string projectRoot)
|
||||
{
|
||||
foreach (KeyValuePair<string, string> dependency in EnumerateManifestDependencies(projectRoot))
|
||||
{
|
||||
string localPath = ResolveLocalDependencyPath(dependency.Value, projectRoot);
|
||||
if (string.IsNullOrEmpty(localPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return ResolveSkillSearchRootCandidate(localPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateDependencyPackageCacheRoots(string projectRoot)
|
||||
{
|
||||
HashSet<string> dependencyNames = new(
|
||||
EnumerateManifestDependencies(projectRoot).Select(dependency => dependency.Key),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
if (dependencyNames.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
string packageCacheRoot = Path.Combine(projectRoot, "Library", "PackageCache");
|
||||
if (!Directory.Exists(packageCacheRoot))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (string packageDirectory in Directory.EnumerateDirectories(packageCacheRoot))
|
||||
{
|
||||
string packageName = Path.GetFileName(packageDirectory);
|
||||
if (string.IsNullOrEmpty(packageName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int separatorIndex = packageName.IndexOf('@');
|
||||
string dependencyName = separatorIndex >= 0 ? packageName.Substring(0, separatorIndex) : packageName;
|
||||
if (!dependencyNames.Contains(dependencyName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return ResolveSkillSearchRootCandidate(packageDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<KeyValuePair<string, string>> EnumerateManifestDependencies(string projectRoot)
|
||||
{
|
||||
string manifestPath = Path.Combine(projectRoot, "Packages", "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
string manifestContent = File.ReadAllText(manifestPath);
|
||||
Match dependenciesMatch = Regex.Match(
|
||||
manifestContent,
|
||||
"\"dependencies\"\\s*:\\s*\\{(?<body>[\\s\\S]*?)\\}",
|
||||
RegexOptions.Multiline);
|
||||
if (!dependenciesMatch.Success)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
MatchCollection dependencyMatches = Regex.Matches(
|
||||
dependenciesMatch.Groups["body"].Value,
|
||||
"\"(?<name>[^\"]+)\"\\s*:\\s*\"(?<value>[^\"]*)\"");
|
||||
foreach (Match dependencyMatch in dependencyMatches)
|
||||
{
|
||||
string dependencyName = dependencyMatch.Groups["name"].Value;
|
||||
string dependencyValue = dependencyMatch.Groups["value"].Value;
|
||||
if (string.IsNullOrEmpty(dependencyName) || string.IsNullOrEmpty(dependencyValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return new KeyValuePair<string, string>(dependencyName, dependencyValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveLocalDependencyPath(string dependencyValue, string projectRoot)
|
||||
{
|
||||
const string FilePrefix = "file:";
|
||||
const string PathPrefix = "path:";
|
||||
|
||||
if (dependencyValue.StartsWith(FilePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return ResolveDependencyPath(dependencyValue.Substring(FilePrefix.Length), projectRoot);
|
||||
}
|
||||
|
||||
if (dependencyValue.StartsWith(PathPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return ResolveDependencyPath(dependencyValue.Substring(PathPrefix.Length), projectRoot);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ResolveDependencyPath(string rawPath, string projectRoot)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string normalizedPath = rawPath.Trim();
|
||||
if (normalizedPath.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
normalizedPath = normalizedPath.Substring(2);
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(normalizedPath))
|
||||
{
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(projectRoot, normalizedPath));
|
||||
}
|
||||
|
||||
private static string ResolveSkillSearchRootCandidate(string candidate)
|
||||
{
|
||||
string nestedRoot = Path.Combine(candidate, "Packages", "src");
|
||||
if (Directory.Exists(nestedRoot))
|
||||
{
|
||||
return nestedRoot;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateEditorFolders(string basePath, int maxDepth)
|
||||
{
|
||||
return EnumerateEditorFoldersRecursive(basePath, depth: 0, maxDepth);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateEditorFoldersRecursive(
|
||||
string currentPath,
|
||||
int depth,
|
||||
int maxDepth)
|
||||
{
|
||||
if (depth > maxDepth || !Directory.Exists(currentPath))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (string directory in Directory.EnumerateDirectories(currentPath))
|
||||
{
|
||||
string directoryName = Path.GetFileName(directory);
|
||||
if (string.Equals(directoryName, "Editor", StringComparison.Ordinal))
|
||||
{
|
||||
yield return directory;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string editorDirectory in EnumerateEditorFoldersRecursive(directory, depth + 1, maxDepth))
|
||||
{
|
||||
yield return editorDirectory;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ParseToolNameFromFrontmatter(string content)
|
||||
{
|
||||
Match frontmatterMatch = Regex.Match(content, @"^---\r?\n([\s\S]*?)\r?\n---");
|
||||
if (!frontmatterMatch.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string frontmatter = frontmatterMatch.Groups[1].Value;
|
||||
Match toolNameMatch = Regex.Match(frontmatter, @"^toolName:\s*(.+)$", RegexOptions.Multiline);
|
||||
if (!toolNameMatch.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return toolNameMatch.Groups[1].Value.Trim();
|
||||
}
|
||||
|
||||
private static string ParseNameFromFrontmatter(string content)
|
||||
{
|
||||
Match frontmatterMatch = Regex.Match(content, @"^---\r?\n([\s\S]*?)\r?\n---");
|
||||
if (!frontmatterMatch.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string frontmatter = frontmatterMatch.Groups[1].Value;
|
||||
Match nameMatch = Regex.Match(frontmatter, @"^name:\s*(.+)$", RegexOptions.Multiline);
|
||||
if (!nameMatch.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return nameMatch.Groups[1].Value.Trim().Trim('"');
|
||||
}
|
||||
|
||||
private static bool IsInternalSkill(string content)
|
||||
{
|
||||
Match frontmatterMatch = Regex.Match(content, @"^---\r?\n([\s\S]*?)\r?\n---");
|
||||
if (!frontmatterMatch.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string frontmatter = frontmatterMatch.Groups[1].Value;
|
||||
Match internalMatch = Regex.Match(frontmatter, @"^internal:\s*(.+)$", RegexOptions.Multiline);
|
||||
if (!internalMatch.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(
|
||||
internalMatch.Groups[1].Value.Trim(),
|
||||
"true",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsExcludedFile(string fileName)
|
||||
{
|
||||
if (ExcludedFileNames.Contains(fileName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (string excludedPattern in ExcludedFileNames)
|
||||
{
|
||||
if (fileName.EndsWith(excludedPattern, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsSafeSkillPathComponent(string skillName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(skillName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (skillName == "." || skillName == "..")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (skillName.Contains('/') || skillName.Contains('\\'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(skillName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (skillName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(Path.GetFileName(skillName), skillName, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eae955b7b19094c3c812d3b1c6e469cc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Thrown when a disabled tool is invoked.
|
||||
/// </summary>
|
||||
public class ToolDisabledException : Exception
|
||||
{
|
||||
public string ToolName { get; }
|
||||
|
||||
public ToolDisabledException(string toolName)
|
||||
: base($"Tool '{toolName}' is disabled")
|
||||
{
|
||||
ToolName = toolName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 65a856359e56649e29149a948ea43225
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Tool toggle settings management for .uloop/settings.tools.json.
|
||||
/// Controls which tools are enabled/disabled in the MCP tool list.
|
||||
/// </summary>
|
||||
public static class ToolSettings
|
||||
{
|
||||
private static string SettingsFilePath =>
|
||||
Path.Combine(McpConstants.ULOOP_DIR, McpConstants.ULOOP_TOOL_SETTINGS_FILE_NAME);
|
||||
|
||||
private static ToolSettingsData _cachedSettings;
|
||||
|
||||
public static ToolSettingsData GetSettings()
|
||||
{
|
||||
if (_cachedSettings == null)
|
||||
{
|
||||
LoadSettings();
|
||||
}
|
||||
return _cachedSettings;
|
||||
}
|
||||
|
||||
public static void SaveSettings(ToolSettingsData settings)
|
||||
{
|
||||
Debug.Assert(settings != null, "settings must not be null");
|
||||
|
||||
ToolSettingsData normalizedSettings = NormalizeSettings(settings);
|
||||
string directory = Path.GetDirectoryName(SettingsFilePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
string json = JsonUtility.ToJson(normalizedSettings, true);
|
||||
|
||||
Debug.Assert(json.Length <= McpConstants.MAX_SETTINGS_SIZE_BYTES,
|
||||
"Settings JSON content exceeds size limit");
|
||||
|
||||
AtomicFileWriter.Write(SettingsFilePath, json);
|
||||
_cachedSettings = normalizedSettings;
|
||||
|
||||
AtomicFileWriter.CleanupBackup(SettingsFilePath + ".bak");
|
||||
}
|
||||
|
||||
public static bool IsToolEnabled(string toolName)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
|
||||
|
||||
string[] disabledTools = GetSettings().disabledTools;
|
||||
return !disabledTools.Contains(toolName);
|
||||
}
|
||||
|
||||
public static void SetToolEnabled(string toolName, bool enabled)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
|
||||
|
||||
ToolSettingsData settings = GetSettings();
|
||||
string[] currentDisabled = settings.disabledTools;
|
||||
|
||||
string[] newDisabled;
|
||||
if (enabled)
|
||||
{
|
||||
newDisabled = currentDisabled.Where(t => t != toolName).ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (currentDisabled.Contains(toolName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
newDisabled = currentDisabled.Append(toolName).ToArray();
|
||||
}
|
||||
|
||||
ToolSettingsData updated = settings with { disabledTools = newDisabled };
|
||||
SaveSettings(updated);
|
||||
}
|
||||
|
||||
public static string[] GetDisabledTools()
|
||||
{
|
||||
return GetSettings().disabledTools;
|
||||
}
|
||||
|
||||
public static string GetSkillCliInvocation()
|
||||
{
|
||||
return NormalizeSkillCliInvocation(GetSettings().skillCliInvocation);
|
||||
}
|
||||
|
||||
public static void SetSkillCliInvocation(string invocation)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(invocation), "invocation must not be null or empty");
|
||||
|
||||
ToolSettingsData settings = GetSettings();
|
||||
string normalizedInvocation = NormalizeSkillCliInvocation(invocation);
|
||||
if (settings.skillCliInvocation == normalizedInvocation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ToolSettingsData updated = settings with { skillCliInvocation = normalizedInvocation };
|
||||
SaveSettings(updated);
|
||||
}
|
||||
|
||||
public static void InvalidateCache()
|
||||
{
|
||||
_cachedSettings = null;
|
||||
}
|
||||
|
||||
private static void LoadSettings()
|
||||
{
|
||||
AtomicFileWriter.RecoverSidecarFiles(SettingsFilePath);
|
||||
|
||||
if (File.Exists(SettingsFilePath))
|
||||
{
|
||||
FileInfo fileInfo = new FileInfo(SettingsFilePath);
|
||||
Debug.Assert(fileInfo.Length <= McpConstants.MAX_SETTINGS_SIZE_BYTES,
|
||||
$"Settings file exceeds size limit: {fileInfo.Length} bytes");
|
||||
|
||||
string json = File.ReadAllText(SettingsFilePath);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
_cachedSettings = new ToolSettingsData();
|
||||
return;
|
||||
}
|
||||
|
||||
ToolSettingsData loaded = JsonUtility.FromJson<ToolSettingsData>(json);
|
||||
if (loaded == null)
|
||||
{
|
||||
_cachedSettings = new ToolSettingsData();
|
||||
return;
|
||||
}
|
||||
_cachedSettings = NormalizeSettings(loaded);
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedSettings = new ToolSettingsData();
|
||||
}
|
||||
|
||||
private static ToolSettingsData NormalizeSettings(ToolSettingsData settings)
|
||||
{
|
||||
Debug.Assert(settings != null, "settings must not be null");
|
||||
|
||||
string[] disabledTools = settings.disabledTools ?? Array.Empty<string>();
|
||||
string skillCliInvocation = NormalizeSkillCliInvocation(settings.skillCliInvocation);
|
||||
return settings with
|
||||
{
|
||||
disabledTools = disabledTools,
|
||||
skillCliInvocation = skillCliInvocation
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeSkillCliInvocation(string invocation)
|
||||
{
|
||||
return invocation == CliConstants.SKILL_CLI_INVOCATION_GLOBAL
|
||||
? CliConstants.SKILL_CLI_INVOCATION_GLOBAL
|
||||
: CliConstants.SKILL_CLI_INVOCATION_NPX;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c1373f1071344961b485f49aaa31f1a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
[Serializable]
|
||||
public record ToolSettingsData
|
||||
{
|
||||
public string[] disabledTools = Array.Empty<string>();
|
||||
public string skillCliInvocation = CliConstants.SKILL_CLI_INVOCATION_NPX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 28376f0a2aebe4909bb91e8b31da908c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,974 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
public enum SkillInstallState
|
||||
{
|
||||
Missing,
|
||||
Checking,
|
||||
Installed,
|
||||
Outdated
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronizes skill files when tools are enabled/disabled.
|
||||
/// Removes skill directories on disable, re-installs on enable.
|
||||
/// </summary>
|
||||
public static class ToolSkillSynchronizer
|
||||
{
|
||||
public readonly struct SkillTargetDefinition
|
||||
{
|
||||
public readonly string DirName;
|
||||
public readonly string Flag;
|
||||
public readonly string DisplayName;
|
||||
|
||||
public SkillTargetDefinition(string dirName, string flag, string displayName)
|
||||
{
|
||||
DirName = dirName;
|
||||
Flag = flag;
|
||||
DisplayName = displayName;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct SkillInstallResult
|
||||
{
|
||||
public readonly int AttemptedTargets;
|
||||
public readonly int SucceededTargets;
|
||||
|
||||
public SkillInstallResult(int attemptedTargets, int succeededTargets)
|
||||
{
|
||||
AttemptedTargets = attemptedTargets;
|
||||
SucceededTargets = succeededTargets;
|
||||
}
|
||||
|
||||
public int FailedTargets => AttemptedTargets - SucceededTargets;
|
||||
public bool IsSuccessful => FailedTargets == 0;
|
||||
}
|
||||
|
||||
public readonly struct SkillTargetInfo
|
||||
{
|
||||
public readonly string DisplayName;
|
||||
public readonly string DirName;
|
||||
public readonly string InstallFlag;
|
||||
public readonly bool HasSkillsDirectory;
|
||||
public readonly bool HasExistingSkills;
|
||||
public readonly bool HasDifferentLayoutSkills;
|
||||
public readonly SkillInstallState InstallState;
|
||||
|
||||
public SkillTargetInfo(
|
||||
string displayName,
|
||||
string dirName,
|
||||
string installFlag,
|
||||
bool hasSkillsDirectory,
|
||||
bool hasExistingSkills,
|
||||
bool hasDifferentLayoutSkills = false,
|
||||
SkillInstallState installState = SkillInstallState.Missing)
|
||||
{
|
||||
DisplayName = displayName;
|
||||
DirName = dirName;
|
||||
InstallFlag = installFlag;
|
||||
HasSkillsDirectory = hasSkillsDirectory;
|
||||
HasExistingSkills = hasExistingSkills;
|
||||
HasDifferentLayoutSkills = hasDifferentLayoutSkills;
|
||||
InstallState = installState;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly SkillTargetDefinition[] SkillTargets =
|
||||
{
|
||||
new(".claude", "--claude", "Claude Code"),
|
||||
new(".cursor", "--cursor", "Cursor"),
|
||||
new(".gemini", "--gemini", "Gemini CLI"),
|
||||
new(".codex", "--codex", "Codex CLI"),
|
||||
new(".agents", "--agents", "Other (.agents)"),
|
||||
new(".agent", "--antigravity", "Antigravity")
|
||||
};
|
||||
|
||||
private static readonly string[] DeprecatedSkillNames =
|
||||
{
|
||||
"uloop-capture-window",
|
||||
"uloop-get-provider-details",
|
||||
"uloop-unity-search",
|
||||
"uloop-get-menu-items",
|
||||
"uloop-get-unity-search-providers",
|
||||
"uloop-execute-menu-item"
|
||||
};
|
||||
private static readonly string[] ExcludedSkillFileNames =
|
||||
{
|
||||
".meta",
|
||||
".DS_Store",
|
||||
".gitkeep"
|
||||
};
|
||||
|
||||
internal static readonly string[] SkillTargetDirs = SkillTargets.Select(t => t.DirName).ToArray();
|
||||
|
||||
public static void RemoveSkillFiles(string toolName)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
|
||||
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
RemoveSkillFilesAtProjectRoot(projectRoot, toolName);
|
||||
}
|
||||
|
||||
internal static void RemoveSkillFilesAtProjectRoot(string projectRoot, string toolName)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
|
||||
|
||||
foreach (string targetDir in SkillTargetDirs)
|
||||
{
|
||||
string targetRoot = Path.Combine(projectRoot, targetDir);
|
||||
if (!Directory.Exists(targetRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string skillDir in SkillInstallLayout.EnumerateInstalledSkillDirectories(targetRoot))
|
||||
{
|
||||
if (SkillInstallLayout.SkillMatchesTool(skillDir, toolName))
|
||||
{
|
||||
Debug.Log($"[uLoopMCP] Removing skill '{toolName}' from '{skillDir}'");
|
||||
Directory.Delete(skillDir, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsSkillInstalled(string toolName)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
|
||||
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
|
||||
foreach (string targetDir in SkillTargetDirs)
|
||||
{
|
||||
string targetRoot = Path.Combine(projectRoot, targetDir);
|
||||
if (!Directory.Exists(targetRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string skillDir in SkillInstallLayout.EnumerateInstalledSkillDirectories(targetRoot))
|
||||
{
|
||||
if (SkillInstallLayout.SkillMatchesTool(skillDir, toolName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static List<SkillTargetInfo> DetectTargets()
|
||||
{
|
||||
return DetectTargets(requireSkillsDirectory: false);
|
||||
}
|
||||
|
||||
public static List<SkillTargetInfo> DetectTargetsForLayout(bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
|
||||
return DetectTargetsForLayoutAtProjectRoot(projectRoot, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
public static List<SkillTargetInfo> DetectTargetsForLayoutFast(bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
|
||||
return DetectTargetsForLayoutFastAtProjectRoot(projectRoot, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
internal static List<SkillTargetInfo> DetectTargetsForLayoutAtProjectRoot(
|
||||
string projectRoot,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
|
||||
return DetectTargets(
|
||||
projectRoot,
|
||||
requireSkillsDirectory: false,
|
||||
groupSkillsUnderUnityCliLoop,
|
||||
includeFreshnessCheck: true);
|
||||
}
|
||||
|
||||
internal static List<SkillTargetInfo> DetectTargetsForLayoutFastAtProjectRoot(
|
||||
string projectRoot,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
|
||||
return DetectTargets(
|
||||
projectRoot,
|
||||
requireSkillsDirectory: false,
|
||||
groupSkillsUnderUnityCliLoop,
|
||||
includeFreshnessCheck: false);
|
||||
}
|
||||
|
||||
internal static List<SkillTargetInfo> DetectTargets(bool requireSkillsDirectory)
|
||||
{
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
|
||||
return DetectTargets(projectRoot, requireSkillsDirectory);
|
||||
}
|
||||
|
||||
internal static List<SkillTargetInfo> DetectTargets(
|
||||
bool requireSkillsDirectory,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
|
||||
return DetectTargets(
|
||||
projectRoot,
|
||||
requireSkillsDirectory,
|
||||
groupSkillsUnderUnityCliLoop,
|
||||
includeFreshnessCheck: true);
|
||||
}
|
||||
|
||||
internal static List<SkillTargetInfo> DetectTargets(
|
||||
bool requireSkillsDirectory,
|
||||
bool groupSkillsUnderUnityCliLoop,
|
||||
bool includeFreshnessCheck)
|
||||
{
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
|
||||
return DetectTargets(
|
||||
projectRoot,
|
||||
requireSkillsDirectory,
|
||||
groupSkillsUnderUnityCliLoop,
|
||||
includeFreshnessCheck);
|
||||
}
|
||||
|
||||
internal static List<SkillTargetInfo> DetectTargets(string projectRoot, bool requireSkillsDirectory)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
|
||||
List<SkillTargetInfo> targets = new();
|
||||
|
||||
foreach (SkillTargetDefinition target in SkillTargets)
|
||||
{
|
||||
string targetRoot = Path.Combine(projectRoot, target.DirName);
|
||||
if (!Directory.Exists(targetRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasSkillsDirectory = SkillInstallLayout.HasOptedInSkillsDirectory(targetRoot);
|
||||
if (requireSkillsDirectory && !hasSkillsDirectory)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasULoopSkills = hasSkillsDirectory
|
||||
&& SkillInstallLayout.HasInstalledSkills(projectRoot, targetRoot);
|
||||
targets.Add(new SkillTargetInfo(
|
||||
target.DisplayName,
|
||||
target.DirName,
|
||||
target.Flag,
|
||||
hasSkillsDirectory,
|
||||
hasULoopSkills,
|
||||
installState: hasULoopSkills
|
||||
? SkillInstallState.Installed
|
||||
: SkillInstallState.Missing));
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
internal static List<SkillTargetInfo> DetectTargets(
|
||||
string projectRoot,
|
||||
bool requireSkillsDirectory,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
return DetectTargets(
|
||||
projectRoot,
|
||||
requireSkillsDirectory,
|
||||
groupSkillsUnderUnityCliLoop,
|
||||
includeFreshnessCheck: true);
|
||||
}
|
||||
|
||||
internal static List<SkillTargetInfo> DetectTargets(
|
||||
string projectRoot,
|
||||
bool requireSkillsDirectory,
|
||||
bool groupSkillsUnderUnityCliLoop,
|
||||
bool includeFreshnessCheck)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
|
||||
List<SkillTargetInfo> targets = new();
|
||||
|
||||
foreach (SkillTargetDefinition target in SkillTargets)
|
||||
{
|
||||
string targetRoot = Path.Combine(projectRoot, target.DirName);
|
||||
if (!Directory.Exists(targetRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasSkillsDirectory = SkillInstallLayout.HasOptedInSkillsDirectory(targetRoot);
|
||||
if (requireSkillsDirectory && !hasSkillsDirectory)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
SkillInstallState installState = ResolveInstallState(
|
||||
projectRoot,
|
||||
targetRoot,
|
||||
hasSkillsDirectory,
|
||||
groupSkillsUnderUnityCliLoop,
|
||||
includeFreshnessCheck);
|
||||
bool hasULoopSkills = installState == SkillInstallState.Installed
|
||||
|| installState == SkillInstallState.Checking
|
||||
|| installState == SkillInstallState.Outdated;
|
||||
bool hasDifferentLayoutSkills = hasSkillsDirectory
|
||||
&& SkillInstallLayout.HasInstalledSkills(projectRoot, targetRoot, !groupSkillsUnderUnityCliLoop);
|
||||
targets.Add(new SkillTargetInfo(
|
||||
target.DisplayName,
|
||||
target.DirName,
|
||||
target.Flag,
|
||||
hasSkillsDirectory,
|
||||
hasULoopSkills,
|
||||
hasDifferentLayoutSkills,
|
||||
installState));
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static SkillInstallState ResolveInstallState(
|
||||
string projectRoot,
|
||||
string targetRoot,
|
||||
bool hasSkillsDirectory,
|
||||
bool groupSkillsUnderUnityCliLoop,
|
||||
bool includeFreshnessCheck)
|
||||
{
|
||||
if (!hasSkillsDirectory)
|
||||
{
|
||||
return SkillInstallState.Missing;
|
||||
}
|
||||
|
||||
if (!includeFreshnessCheck)
|
||||
{
|
||||
return SkillInstallLayout.HasInstalledSkills(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop)
|
||||
? SkillInstallState.Checking
|
||||
: SkillInstallState.Missing;
|
||||
}
|
||||
|
||||
return SkillInstallLayout.GetInstalledState(projectRoot, targetRoot, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-installs skills only for targets that already opted in via an existing skills directory.
|
||||
/// </summary>
|
||||
public static async Task<SkillInstallResult> InstallSkillFiles()
|
||||
{
|
||||
return await InstallSkillFiles(groupSkillsUnderUnityCliLoop: false);
|
||||
}
|
||||
|
||||
public static async Task<SkillInstallResult> InstallSkillFiles(bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
List<SkillTargetInfo> targets = DetectTargets(requireSkillsDirectory: true);
|
||||
return await InstallSkillFiles(targets, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
public static async Task<SkillInstallResult> InstallSkillFilesForTool(
|
||||
string toolName,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
|
||||
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
|
||||
return await InstallSkillFilesForToolAtProjectRoot(
|
||||
projectRoot,
|
||||
toolName,
|
||||
groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
public static async Task<SkillInstallResult> InstallSkillFiles(List<SkillTargetInfo> targets)
|
||||
{
|
||||
return await InstallSkillFiles(targets, groupSkillsUnderUnityCliLoop: false);
|
||||
}
|
||||
|
||||
public static async Task<SkillInstallResult> InstallSkillFiles(
|
||||
List<SkillTargetInfo> targets,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
Debug.Assert(targets != null, "targets must not be null");
|
||||
string projectRoot = UnityMcpPathResolver.GetProjectRoot();
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
|
||||
return await InstallSkillFilesAtProjectRoot(projectRoot, targets, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
internal static async Task<SkillInstallResult> InstallSkillFilesAtProjectRoot(
|
||||
string projectRoot,
|
||||
IEnumerable<SkillTargetInfo> targets,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
Debug.Assert(targets != null, "targets must not be null");
|
||||
|
||||
SkillTargetInfo[] targetArray = targets.ToArray();
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
string[] disabledTools = ToolSettings.GetDisabledTools();
|
||||
List<SkillInstallLayout.SkillSourceInfo> allSkills = SkillInstallLayout.GetSkillSourceInfos(projectRoot);
|
||||
List<SkillInstallLayout.SkillSourceInfo> disabledSkills = allSkills
|
||||
.Where(skill => IsSkillDisabledByToolSettings(
|
||||
skill,
|
||||
disabledTools,
|
||||
ToolExecutionAvailability.IsTestFrameworkAvailable))
|
||||
.ToList();
|
||||
List<SkillInstallLayout.SkillSourceInfo> enabledSkills = allSkills
|
||||
.Except(disabledSkills)
|
||||
.ToList();
|
||||
|
||||
int succeeded = 0;
|
||||
foreach (SkillTargetInfo target in targetArray)
|
||||
{
|
||||
InstallSkillsForTarget(
|
||||
projectRoot,
|
||||
target,
|
||||
disabledSkills,
|
||||
enabledSkills,
|
||||
groupSkillsUnderUnityCliLoop);
|
||||
succeeded++;
|
||||
}
|
||||
|
||||
return new SkillInstallResult(targetArray.Length, succeeded);
|
||||
});
|
||||
}
|
||||
|
||||
internal static async Task<SkillInstallResult> InstallSkillFilesForToolAtProjectRoot(
|
||||
string projectRoot,
|
||||
string toolName,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(projectRoot), "projectRoot must not be null or empty");
|
||||
Debug.Assert(!string.IsNullOrEmpty(toolName), "toolName must not be null or empty");
|
||||
|
||||
SkillTargetInfo[] targetArray = DetectTargetsWithSkillsDirectory(projectRoot).ToArray();
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
string[] disabledTools = ToolSettings.GetDisabledTools();
|
||||
List<SkillInstallLayout.SkillSourceInfo> allSkills = SkillInstallLayout.GetSkillSourceInfos(projectRoot);
|
||||
List<SkillInstallLayout.SkillSourceInfo> disabledSkills = allSkills
|
||||
.Where(skill => IsSkillDisabledByToolSettings(
|
||||
skill,
|
||||
disabledTools,
|
||||
ToolExecutionAvailability.IsTestFrameworkAvailable))
|
||||
.ToList();
|
||||
List<SkillInstallLayout.SkillSourceInfo> toolSkills = allSkills
|
||||
.Where(skill => IsSkillForTool(skill, toolName))
|
||||
.ToList();
|
||||
if (toolSkills.Count == 0)
|
||||
{
|
||||
return new SkillInstallResult(0, 0);
|
||||
}
|
||||
|
||||
int succeeded = 0;
|
||||
foreach (SkillTargetInfo target in targetArray)
|
||||
{
|
||||
InstallSpecificSkillsForTarget(
|
||||
projectRoot,
|
||||
target,
|
||||
disabledSkills,
|
||||
toolSkills,
|
||||
groupSkillsUnderUnityCliLoop);
|
||||
succeeded++;
|
||||
}
|
||||
|
||||
return new SkillInstallResult(targetArray.Length, succeeded);
|
||||
});
|
||||
}
|
||||
|
||||
private static void InstallSkillsForTarget(
|
||||
string projectRoot,
|
||||
SkillTargetInfo target,
|
||||
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> disabledSkills,
|
||||
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> enabledSkills,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
string targetRoot = Path.Combine(projectRoot, target.DirName);
|
||||
string skillsRoot = SkillInstallLayout.GetSkillsRoot(targetRoot);
|
||||
HashSet<string> managedSkillNames = new(
|
||||
enabledSkills.Select(skill => skill.Name),
|
||||
StringComparer.Ordinal);
|
||||
managedSkillNames.UnionWith(disabledSkills.Select(skill => skill.Name));
|
||||
managedSkillNames.UnionWith(DeprecatedSkillNames);
|
||||
Directory.CreateDirectory(skillsRoot);
|
||||
|
||||
if (groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
Directory.CreateDirectory(SkillInstallLayout.GetManagedSkillsRoot(targetRoot));
|
||||
}
|
||||
|
||||
DeleteDeprecatedSkillDirectoriesFromAllLayouts(targetRoot);
|
||||
DeleteDisabledSkillDirectoriesFromAllLayouts(targetRoot, disabledSkills);
|
||||
|
||||
foreach (SkillInstallLayout.SkillSourceInfo skill in enabledSkills)
|
||||
{
|
||||
string installedSkillDirectory = SkillInstallLayout.GetInstalledSkillDirectoryPathForLayout(
|
||||
targetRoot,
|
||||
skill.Name,
|
||||
groupSkillsUnderUnityCliLoop);
|
||||
SyncInstalledSkillDirectory(installedSkillDirectory, skill.SkillFiles);
|
||||
DeleteSkillDirectoryIfExists(targetRoot, skill.Name, !groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
DeleteUnexpectedInstalledSkillDirectories(
|
||||
targetRoot,
|
||||
enabledSkills.Select(skill => skill.Name),
|
||||
managedSkillNames,
|
||||
groupSkillsUnderUnityCliLoop);
|
||||
|
||||
if (!groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
DeleteUnexpectedInstalledSkillDirectories(
|
||||
targetRoot,
|
||||
enabledSkills.Select(skill => skill.Name),
|
||||
managedSkillNames,
|
||||
groupSkillsUnderUnityCliLoop: true);
|
||||
DeleteEmptyManagedSkillsParentDirectoryIfNeeded(
|
||||
targetRoot,
|
||||
groupSkillsUnderUnityCliLoop: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InstallSpecificSkillsForTarget(
|
||||
string projectRoot,
|
||||
SkillTargetInfo target,
|
||||
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> disabledSkills,
|
||||
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> skills,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
string targetRoot = Path.Combine(projectRoot, target.DirName);
|
||||
string skillsRoot = SkillInstallLayout.GetSkillsRoot(targetRoot);
|
||||
Directory.CreateDirectory(skillsRoot);
|
||||
|
||||
if (groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
Directory.CreateDirectory(SkillInstallLayout.GetManagedSkillsRoot(targetRoot));
|
||||
}
|
||||
|
||||
DeleteDeprecatedSkillDirectoriesForLayout(targetRoot, groupSkillsUnderUnityCliLoop);
|
||||
DeleteDisabledSkillDirectoriesForLayout(targetRoot, disabledSkills, groupSkillsUnderUnityCliLoop);
|
||||
|
||||
foreach (SkillInstallLayout.SkillSourceInfo skill in skills)
|
||||
{
|
||||
string installedSkillDirectory = SkillInstallLayout.GetInstalledSkillDirectoryPathForLayout(
|
||||
targetRoot,
|
||||
skill.Name,
|
||||
groupSkillsUnderUnityCliLoop);
|
||||
SyncInstalledSkillDirectory(installedSkillDirectory, skill.SkillFiles);
|
||||
DeleteSkillDirectoryIfExists(targetRoot, skill.Name, !groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
if (!groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
DeleteEmptyManagedSkillsParentDirectoryIfNeeded(
|
||||
targetRoot,
|
||||
groupSkillsUnderUnityCliLoop: true);
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsSkillDisabledByToolSettings(
|
||||
SkillInstallLayout.SkillSourceInfo skill,
|
||||
IReadOnlyCollection<string> disabledTools,
|
||||
bool isTestFrameworkAvailable)
|
||||
{
|
||||
if (disabledTools.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string toolName = GetToolNameForSkill(skill);
|
||||
if (string.IsNullOrEmpty(toolName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return disabledTools.Contains(toolName)
|
||||
&& !ToolExecutionAvailability.ShouldReportDependencyUnavailableBeforeDisabled(
|
||||
toolName,
|
||||
isTestFrameworkAvailable);
|
||||
}
|
||||
|
||||
private static bool IsSkillForTool(
|
||||
SkillInstallLayout.SkillSourceInfo skill,
|
||||
string toolName)
|
||||
{
|
||||
string skillToolName = GetToolNameForSkill(skill);
|
||||
return string.Equals(skillToolName, toolName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string GetToolNameForSkill(SkillInstallLayout.SkillSourceInfo skill)
|
||||
{
|
||||
string toolName = skill.ToolName;
|
||||
if (string.IsNullOrEmpty(toolName) && skill.Name.StartsWith(CliConstants.SKILL_DIR_PREFIX, StringComparison.Ordinal))
|
||||
{
|
||||
toolName = skill.Name.Substring(CliConstants.SKILL_DIR_PREFIX.Length);
|
||||
}
|
||||
|
||||
return toolName;
|
||||
}
|
||||
|
||||
private static List<SkillTargetInfo> DetectTargetsWithSkillsDirectory(string projectRoot)
|
||||
{
|
||||
List<SkillTargetInfo> targets = new();
|
||||
foreach (SkillTargetDefinition target in SkillTargets)
|
||||
{
|
||||
string targetRoot = Path.Combine(projectRoot, target.DirName);
|
||||
if (!Directory.Exists(targetRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool hasSkillsDirectory = SkillInstallLayout.HasOptedInSkillsDirectory(targetRoot);
|
||||
if (!hasSkillsDirectory)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
targets.Add(new SkillTargetInfo(
|
||||
target.DisplayName,
|
||||
target.DirName,
|
||||
target.Flag,
|
||||
hasSkillsDirectory,
|
||||
hasExistingSkills: false));
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static void DeleteUnexpectedInstalledSkillDirectories(
|
||||
string targetRoot,
|
||||
IEnumerable<string> expectedSkillNames,
|
||||
IEnumerable<string> removableSkillNames,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
HashSet<string> expectedSkillNameSet = new(expectedSkillNames, StringComparer.Ordinal);
|
||||
HashSet<string> removableSkillNameSet = new(removableSkillNames, StringComparer.Ordinal);
|
||||
foreach (string installedSkillName in SkillInstallLayout.EnumerateInstalledSkillDirectoryNamesForLayout(
|
||||
targetRoot,
|
||||
groupSkillsUnderUnityCliLoop))
|
||||
{
|
||||
if (expectedSkillNameSet.Contains(installedSkillName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!removableSkillNameSet.Contains(installedSkillName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DeleteSkillDirectoryIfExists(targetRoot, installedSkillName, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SyncInstalledSkillDirectory(
|
||||
string skillDirectory,
|
||||
IReadOnlyDictionary<string, byte[]> skillFiles)
|
||||
{
|
||||
Debug.Assert(!string.IsNullOrEmpty(skillDirectory), "skillDirectory must not be null or empty");
|
||||
Debug.Assert(skillFiles != null, "skillFiles must not be null");
|
||||
Debug.Assert(skillFiles.ContainsKey(SkillInstallLayout.SkillFileName),
|
||||
"skillFiles must contain SKILL.md");
|
||||
|
||||
string parentDirectory = Path.GetDirectoryName(skillDirectory);
|
||||
Debug.Assert(!string.IsNullOrEmpty(parentDirectory), "parentDirectory must not be null or empty");
|
||||
Directory.CreateDirectory(parentDirectory);
|
||||
bool skillDirectoryExisted = Directory.Exists(skillDirectory);
|
||||
Dictionary<string, byte[]> backupFiles = skillDirectoryExisted
|
||||
? ReadSkillFiles(skillDirectory)
|
||||
: new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||
Directory.CreateDirectory(skillDirectory);
|
||||
|
||||
bool syncCompleted = false;
|
||||
try
|
||||
{
|
||||
WriteSkillFiles(skillDirectory, skillFiles);
|
||||
DeleteUnexpectedSkillFiles(skillDirectory, skillFiles.Keys);
|
||||
DeleteEmptyDirectories(skillDirectory);
|
||||
syncCompleted = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!syncCompleted)
|
||||
{
|
||||
RollbackSkillDirectory(skillDirectory, backupFiles, skillDirectoryExisted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteDeprecatedSkillDirectoriesFromAllLayouts(string targetRoot)
|
||||
{
|
||||
foreach (string deprecatedSkillName in DeprecatedSkillNames)
|
||||
{
|
||||
DeleteSkillDirectoryIfExists(targetRoot, deprecatedSkillName, groupSkillsUnderUnityCliLoop: true);
|
||||
DeleteSkillDirectoryIfExists(targetRoot, deprecatedSkillName, groupSkillsUnderUnityCliLoop: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteDisabledSkillDirectoriesFromAllLayouts(
|
||||
string targetRoot,
|
||||
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> disabledSkills)
|
||||
{
|
||||
foreach (SkillInstallLayout.SkillSourceInfo skill in disabledSkills)
|
||||
{
|
||||
DeleteSkillDirectoryIfExists(targetRoot, skill.Name, groupSkillsUnderUnityCliLoop: true);
|
||||
DeleteSkillDirectoryIfExists(targetRoot, skill.Name, groupSkillsUnderUnityCliLoop: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteDeprecatedSkillDirectoriesForLayout(
|
||||
string targetRoot,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
foreach (string deprecatedSkillName in DeprecatedSkillNames)
|
||||
{
|
||||
DeleteSkillDirectoryIfExists(targetRoot, deprecatedSkillName, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteDisabledSkillDirectoriesForLayout(
|
||||
string targetRoot,
|
||||
IReadOnlyCollection<SkillInstallLayout.SkillSourceInfo> disabledSkills,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
foreach (SkillInstallLayout.SkillSourceInfo skill in disabledSkills)
|
||||
{
|
||||
DeleteSkillDirectoryIfExists(targetRoot, skill.Name, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteSkillFiles(
|
||||
string skillDirectory,
|
||||
IReadOnlyDictionary<string, byte[]> skillFiles)
|
||||
{
|
||||
foreach (KeyValuePair<string, byte[]> skillFile in skillFiles)
|
||||
{
|
||||
string fullPath = Path.Combine(skillDirectory, skillFile.Key);
|
||||
string fileDirectory = Path.GetDirectoryName(fullPath);
|
||||
Debug.Assert(!string.IsNullOrEmpty(fileDirectory), "fileDirectory must not be null or empty");
|
||||
Directory.CreateDirectory(fileDirectory);
|
||||
if (File.Exists(fullPath) && File.ReadAllBytes(fullPath).SequenceEqual(skillFile.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
WriteFileAtomically(fullPath, skillFile.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, byte[]> ReadSkillFiles(string skillDirectory)
|
||||
{
|
||||
Dictionary<string, byte[]> files = new(StringComparer.Ordinal);
|
||||
foreach (string filePath in Directory.EnumerateFiles(skillDirectory, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string fileName = Path.GetFileName(filePath);
|
||||
if (IsExcludedSkillFile(fileName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string relativePath = Path.GetRelativePath(skillDirectory, filePath);
|
||||
files[relativePath] = File.ReadAllBytes(filePath);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static void WriteFileAtomically(string fullPath, byte[] content)
|
||||
{
|
||||
string fileDirectory = Path.GetDirectoryName(fullPath);
|
||||
Debug.Assert(!string.IsNullOrEmpty(fileDirectory), "fileDirectory must not be null or empty");
|
||||
|
||||
string tempPath = Path.Combine(
|
||||
fileDirectory,
|
||||
$"{Path.GetFileName(fullPath)}.tmp-{Guid.NewGuid():N}");
|
||||
File.WriteAllBytes(tempPath, content);
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
File.Replace(tempPath, fullPath, null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
File.Move(tempPath, fullPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteUnexpectedSkillFiles(
|
||||
string skillDirectory,
|
||||
IEnumerable<string> expectedRelativePaths)
|
||||
{
|
||||
HashSet<string> expectedPaths = new(expectedRelativePaths, StringComparer.Ordinal);
|
||||
foreach (string filePath in Directory.EnumerateFiles(skillDirectory, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
string fileName = Path.GetFileName(filePath);
|
||||
if (IsExcludedSkillFile(fileName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string relativePath = Path.GetRelativePath(skillDirectory, filePath);
|
||||
if (expectedPaths.Contains(relativePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteEmptyDirectories(string skillDirectory)
|
||||
{
|
||||
foreach (string directoryPath in Directory.EnumerateDirectories(skillDirectory, "*", SearchOption.AllDirectories)
|
||||
.OrderByDescending(path => path.Length))
|
||||
{
|
||||
if (Directory.EnumerateFileSystemEntries(directoryPath).Any())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Directory.Delete(directoryPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RollbackSkillDirectory(
|
||||
string skillDirectory,
|
||||
IReadOnlyDictionary<string, byte[]> backupFiles,
|
||||
bool skillDirectoryExisted)
|
||||
{
|
||||
if (!skillDirectoryExisted)
|
||||
{
|
||||
if (Directory.Exists(skillDirectory))
|
||||
{
|
||||
Directory.Delete(skillDirectory, true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(skillDirectory);
|
||||
WriteSkillFiles(skillDirectory, backupFiles);
|
||||
DeleteUnexpectedSkillFiles(skillDirectory, backupFiles.Keys);
|
||||
DeleteEmptyDirectories(skillDirectory);
|
||||
}
|
||||
|
||||
private static bool IsExcludedSkillFile(string fileName)
|
||||
{
|
||||
if (ExcludedSkillFileNames.Contains(fileName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (string excludedPattern in ExcludedSkillFileNames)
|
||||
{
|
||||
if (fileName.EndsWith(excludedPattern, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void DeleteSkillDirectoryIfExists(
|
||||
string targetRoot,
|
||||
string skillName,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
string installedSkillDirectory = SkillInstallLayout.GetInstalledSkillDirectoryPathForLayout(
|
||||
targetRoot,
|
||||
skillName,
|
||||
groupSkillsUnderUnityCliLoop);
|
||||
if (!Directory.Exists(installedSkillDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.Delete(installedSkillDirectory, true);
|
||||
DeleteEmptyManagedSkillsParentDirectoryIfNeeded(targetRoot, groupSkillsUnderUnityCliLoop);
|
||||
}
|
||||
|
||||
private static void DeleteEmptyManagedSkillsParentDirectoryIfNeeded(
|
||||
string targetRoot,
|
||||
bool groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
if (!groupSkillsUnderUnityCliLoop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string managedSkillsRoot = SkillInstallLayout.GetManagedSkillsRoot(targetRoot);
|
||||
if (!Directory.Exists(managedSkillsRoot))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DeleteExcludedFilesAtRoot(managedSkillsRoot);
|
||||
DeleteEmptyDirectoriesAtRoot(managedSkillsRoot);
|
||||
if (Directory.EnumerateFileSystemEntries(managedSkillsRoot).Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.Delete(managedSkillsRoot);
|
||||
}
|
||||
|
||||
private static void DeleteExcludedFilesAtRoot(string directoryPath)
|
||||
{
|
||||
foreach (string filePath in Directory.EnumerateFiles(directoryPath))
|
||||
{
|
||||
string fileName = Path.GetFileName(filePath);
|
||||
if (!IsExcludedSkillFile(fileName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteEmptyDirectoriesAtRoot(string directoryPath)
|
||||
{
|
||||
foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath))
|
||||
{
|
||||
DeleteEmptyDirectories(childDirectoryPath);
|
||||
if (Directory.EnumerateFileSystemEntries(childDirectoryPath).Any())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Directory.Delete(childDirectoryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5fa939f7e20a944e6a1a05461187ce13
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,294 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEngine;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Security settings management for .uloop/settings.permissions.json.
|
||||
/// This file is stored in the project root so it can be git-tracked
|
||||
/// and shared across team members as a security policy.
|
||||
/// </summary>
|
||||
public static class ULoopSettings
|
||||
{
|
||||
private static string SettingsFilePath =>
|
||||
Path.Combine(McpConstants.ULOOP_DIR, McpConstants.ULOOP_SETTINGS_FILE_NAME);
|
||||
|
||||
private static string LegacySettingsFilePath =>
|
||||
Path.Combine(McpConstants.USER_SETTINGS_FOLDER, McpConstants.SETTINGS_FILE_NAME);
|
||||
|
||||
private static ULoopSettingsData _cachedSettings;
|
||||
|
||||
public static ULoopSettingsData GetSettings()
|
||||
{
|
||||
if (_cachedSettings == null)
|
||||
{
|
||||
LoadSettings();
|
||||
}
|
||||
return _cachedSettings;
|
||||
}
|
||||
|
||||
public static void SaveSettings(ULoopSettingsData settings)
|
||||
{
|
||||
Debug.Assert(settings != null, "settings must not be null");
|
||||
|
||||
string directory = Path.GetDirectoryName(SettingsFilePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
string json = JsonUtility.ToJson(settings, true);
|
||||
|
||||
Debug.Assert(json.Length <= McpConstants.MAX_SETTINGS_SIZE_BYTES,
|
||||
"Settings JSON content exceeds size limit");
|
||||
|
||||
AtomicFileWriter.Write(SettingsFilePath, json);
|
||||
_cachedSettings = settings;
|
||||
|
||||
AtomicFileWriter.CleanupBackup(SettingsFilePath + ".bak");
|
||||
}
|
||||
|
||||
public static void UpdateSettings(Func<ULoopSettingsData, ULoopSettingsData> transform)
|
||||
{
|
||||
Debug.Assert(transform != null, "transform must not be null");
|
||||
|
||||
ULoopSettingsData current = GetSettings();
|
||||
ULoopSettingsData updated = transform(current);
|
||||
SaveSettings(updated);
|
||||
}
|
||||
|
||||
public static bool GetAllowThirdPartyTools()
|
||||
{
|
||||
return GetSettings().allowThirdPartyTools;
|
||||
}
|
||||
|
||||
public static void SetAllowThirdPartyTools(bool value)
|
||||
{
|
||||
ULoopSettingsData settings = GetSettings();
|
||||
ULoopSettingsData updated = settings with { allowThirdPartyTools = value };
|
||||
SaveSettings(updated);
|
||||
}
|
||||
|
||||
public static DynamicCodeSecurityLevel GetDynamicCodeSecurityLevel()
|
||||
{
|
||||
ULoopSettingsData settings = GetSettings();
|
||||
int persistedValue = settings.dynamicCodeSecurityLevel;
|
||||
|
||||
// Disabled(0) was removed; migrate any undefined value to Restricted and persist
|
||||
if (!Enum.IsDefined(typeof(DynamicCodeSecurityLevel), persistedValue))
|
||||
{
|
||||
DynamicCodeSecurityLevel fallback = DynamicCodeSecurityLevel.Restricted;
|
||||
ULoopSettingsData migrated = settings with { dynamicCodeSecurityLevel = (int)fallback };
|
||||
SaveSettings(migrated);
|
||||
return fallback;
|
||||
}
|
||||
return (DynamicCodeSecurityLevel)persistedValue;
|
||||
}
|
||||
|
||||
public static void SetDynamicCodeSecurityLevel(DynamicCodeSecurityLevel level)
|
||||
{
|
||||
ULoopSettingsData settings = GetSettings();
|
||||
ULoopSettingsData updated = settings with { dynamicCodeSecurityLevel = (int)level };
|
||||
SaveSettings(updated);
|
||||
|
||||
VibeLogger.LogInfo(
|
||||
"editor_settings_security_level_changed",
|
||||
$"Security level changed to: {level}",
|
||||
new { level = level.ToString() },
|
||||
correlationId: McpConstants.GenerateCorrelationId(),
|
||||
humanNote: "Security level updated in editor settings",
|
||||
aiTodo: "Monitor security level changes"
|
||||
);
|
||||
}
|
||||
|
||||
// Loading & Migration
|
||||
|
||||
private static void LoadSettings()
|
||||
{
|
||||
string oldSettingsPath = Path.Combine(McpConstants.ULOOP_DIR, "settings.security.json");
|
||||
string oldBackupPath = oldSettingsPath + ".bak";
|
||||
|
||||
AtomicFileWriter.RecoverSidecarFiles(SettingsFilePath);
|
||||
|
||||
// When upgrading directly from v0.67 (or earlier) to v0.69+, the legacy
|
||||
// file still contains security fields because v0.68's extraction never ran.
|
||||
// Legacy file takes priority over any settings.security.json which may hold
|
||||
// stale default values.
|
||||
if (!File.Exists(SettingsFilePath) && LegacyFileHasSecurityFields())
|
||||
{
|
||||
MigrateFromLegacySettings();
|
||||
DeleteIfExists(oldSettingsPath);
|
||||
DeleteIfExists(oldBackupPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// v0.68.0 used "settings.security.json"; rename once so existing users keep their settings.
|
||||
// This migration block can be removed after a few releases.
|
||||
if (!File.Exists(SettingsFilePath))
|
||||
{
|
||||
if (File.Exists(oldSettingsPath))
|
||||
{
|
||||
File.Move(oldSettingsPath, SettingsFilePath);
|
||||
}
|
||||
else if (File.Exists(oldBackupPath))
|
||||
{
|
||||
File.Move(oldBackupPath, SettingsFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(SettingsFilePath))
|
||||
{
|
||||
FileInfo fileInfo = new FileInfo(SettingsFilePath);
|
||||
Debug.Assert(fileInfo.Length <= McpConstants.MAX_SETTINGS_SIZE_BYTES,
|
||||
$"Settings file exceeds size limit: {fileInfo.Length} bytes");
|
||||
|
||||
string json = File.ReadAllText(SettingsFilePath);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
_cachedSettings = new ULoopSettingsData();
|
||||
return;
|
||||
}
|
||||
|
||||
_cachedSettings = JsonUtility.FromJson<ULoopSettingsData>(json);
|
||||
bool migratedToolToggles = ApplyLegacyToolToggleMigrations(json);
|
||||
bool normalizedDynamicCode = NormalizeLegacyDisabledDynamicCode();
|
||||
if (migratedToolToggles || normalizedDynamicCode)
|
||||
{
|
||||
SaveSettings(_cachedSettings);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// .uloop/settings.permissions.json does not exist yet — attempt migration from legacy file
|
||||
MigrateFromLegacySettings();
|
||||
}
|
||||
|
||||
private static bool LegacyFileHasSecurityFields()
|
||||
{
|
||||
if (!File.Exists(LegacySettingsFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string json = File.ReadAllText(LegacySettingsFilePath);
|
||||
return json.Contains($"\"{nameof(LegacySecuritySettingsProbe.allowThirdPartyTools)}\"")
|
||||
|| json.Contains($"\"{nameof(LegacySecuritySettingsProbe.dynamicCodeSecurityLevel)}\"");
|
||||
}
|
||||
|
||||
private static void DeleteIfExists(string path)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// McpEditorSettingsData no longer contains security fields, so we need
|
||||
/// a dedicated probe class to extract them from the legacy JSON.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
private class LegacySecuritySettingsProbe
|
||||
{
|
||||
public bool enableTestsExecution = true;
|
||||
public bool allowMenuItemExecution = true;
|
||||
public bool allowThirdPartyTools = false;
|
||||
public int dynamicCodeSecurityLevel = (int)DynamicCodeSecurityLevel.Restricted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-time migration: .uloop/settings.permissions.json absence is used as the trigger
|
||||
/// to guarantee this runs exactly once — after migration the file exists and
|
||||
/// this path is never taken again.
|
||||
/// </summary>
|
||||
private static void MigrateFromLegacySettings()
|
||||
{
|
||||
if (!File.Exists(LegacySettingsFilePath))
|
||||
{
|
||||
_cachedSettings = new ULoopSettingsData();
|
||||
return;
|
||||
}
|
||||
|
||||
string legacyJson = File.ReadAllText(LegacySettingsFilePath);
|
||||
if (string.IsNullOrWhiteSpace(legacyJson))
|
||||
{
|
||||
_cachedSettings = new ULoopSettingsData();
|
||||
return;
|
||||
}
|
||||
|
||||
LegacySecuritySettingsProbe probe = JsonUtility.FromJson<LegacySecuritySettingsProbe>(legacyJson);
|
||||
|
||||
_cachedSettings = new ULoopSettingsData
|
||||
{
|
||||
allowThirdPartyTools = probe.allowThirdPartyTools,
|
||||
dynamicCodeSecurityLevel = probe.dynamicCodeSecurityLevel
|
||||
};
|
||||
|
||||
ApplyLegacyToolToggleMigrations(legacyJson);
|
||||
NormalizeLegacyDisabledDynamicCode();
|
||||
SaveSettings(_cachedSettings);
|
||||
|
||||
// Re-save legacy file to purge security fields that are no longer in
|
||||
// McpEditorSettingsData — JsonUtility.ToJson only serializes defined fields,
|
||||
// so the 4 removed fields disappear from the JSON on re-serialization.
|
||||
McpEditorSettings.SaveSettings(McpEditorSettings.GetSettings());
|
||||
}
|
||||
|
||||
private static bool NormalizeLegacyDisabledDynamicCode()
|
||||
{
|
||||
Debug.Assert(_cachedSettings != null, "_cachedSettings must not be null");
|
||||
|
||||
if (_cachedSettings.dynamicCodeSecurityLevel != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ToolSettings.SetToolEnabled(McpConstants.TOOL_NAME_EXECUTE_DYNAMIC_CODE, false);
|
||||
_cachedSettings = _cachedSettings with
|
||||
{
|
||||
dynamicCodeSecurityLevel = (int)DynamicCodeSecurityLevel.Restricted
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ApplyLegacyToolToggleMigrations(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
LegacySecuritySettingsProbe probe = JsonUtility.FromJson<LegacySecuritySettingsProbe>(json);
|
||||
bool migrated = false;
|
||||
|
||||
if (json.Contains($"\"{nameof(LegacySecuritySettingsProbe.enableTestsExecution)}\"")
|
||||
&& !probe.enableTestsExecution)
|
||||
{
|
||||
ToolSettings.SetToolEnabled(McpConstants.TOOL_NAME_RUN_TESTS, false);
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (json.Contains($"\"{nameof(LegacySecuritySettingsProbe.allowMenuItemExecution)}\""))
|
||||
{
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
internal static void InvalidateCache()
|
||||
{
|
||||
_cachedSettings = null;
|
||||
}
|
||||
|
||||
// Roslyn Define Symbol Management
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b02e268ca8d54e7cb1d58fb851328e5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Security settings stored in .uloop/settings.permissions.json.
|
||||
/// This file can be git-tracked for team-wide security policy sharing.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public record ULoopSettingsData
|
||||
{
|
||||
public bool allowThirdPartyTools = false;
|
||||
public int dynamicCodeSecurityLevel = (int)DynamicCodeSecurityLevel.Restricted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 13f53756ed78843e680c0a9cf28305ef
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,615 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using System.Linq; // Added for .Concat()
|
||||
|
||||
namespace io.github.hatayama.uLoopMCP
|
||||
{
|
||||
/// <summary>
|
||||
/// Supported editor types.
|
||||
/// </summary>
|
||||
public enum McpEditorType
|
||||
{
|
||||
Cursor,
|
||||
ClaudeCode,
|
||||
VSCode,
|
||||
GeminiCLI,
|
||||
Windsurf,
|
||||
Codex,
|
||||
McpInspector,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Class for resolving paths related to Unity MCP.
|
||||
/// Single Responsibility Principle: Only responsible for path resolution.
|
||||
///
|
||||
/// ## Architecture Overview
|
||||
///
|
||||
/// This project uses the Visitor pattern to eliminate switch statements and centralize
|
||||
/// editor-specific configuration logic. The EditorConfigProvider class now handles
|
||||
/// all editor-specific configuration retrieval.
|
||||
///
|
||||
/// ## Adding New LLM Tools Support
|
||||
///
|
||||
/// When adding support for a new LLM tool, follow these steps:
|
||||
///
|
||||
/// ### 1. Update McpEditorType enum (this file)
|
||||
/// - Add new enum value (e.g., `NewEditor`)
|
||||
///
|
||||
/// ### 2. Update IEditorConfigVisitor interface
|
||||
/// - Add new Visit method (e.g., `VisitNewEditor()`)
|
||||
///
|
||||
/// ### 3. Update EditorConfigProvider.ConfigurationVisitor
|
||||
/// - Implement the new Visit method with configuration details
|
||||
/// - Return EditorConfig with client name, config path, and config directory
|
||||
///
|
||||
/// ### 4. Update McpEditorTypeExtensions.Accept method
|
||||
/// - Add case for new editor type in switch expression
|
||||
///
|
||||
/// ### 5. Add path resolution methods (this file)
|
||||
/// - Add GetXXXConfigPath() method
|
||||
/// - Add GetXXXConfigDirectory() method (if needed)
|
||||
/// - Add configuration file path constants
|
||||
///
|
||||
/// ### 6. Update McpConstants.cs
|
||||
/// - Add CLIENT_NAME_XXX constant
|
||||
///
|
||||
/// ### 7. Related Classes
|
||||
/// - EditorConfigProvider: Centralized configuration management using Visitor pattern
|
||||
/// - McpConfigService: Handles configuration file read/write operations
|
||||
/// - McpConfigRepository: Manages configuration data persistence
|
||||
/// - McpConfigServiceFactory: Automatically creates services for all enum values
|
||||
/// - McpServerConfigFactory: Creates server configuration objects with editor-specific handling
|
||||
/// - McpEditorSettings: Manages editor-specific settings
|
||||
///
|
||||
/// ### 8. Configuration Details
|
||||
///
|
||||
/// #### Server Key Format:
|
||||
/// - Windsurf: "uLoopMCP-{port}" (includes port number)
|
||||
/// - Other editors: "uLoopMCP" (no port number)
|
||||
///
|
||||
/// #### Server Path Format:
|
||||
/// - Desktop editors (Cursor, VSCode, Windsurf): Absolute path
|
||||
/// - CLI / project-level editors (Claude Code, Gemini CLI, Codex): Relative path from project root
|
||||
///
|
||||
/// #### Environment Variables:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "mcpServers": {
|
||||
/// "uLoopMCP": {
|
||||
/// "command": "node",
|
||||
/// "args": ["{path_to_server.bundle.js}"],
|
||||
/// "env": {
|
||||
/// "UNITY_TCP_PORT": "{port}"
|
||||
/// // MCP_CLIENT_NAME removed - now using clientInfo.name from MCP protocol
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ### 9. Testing Checklist
|
||||
/// - Verify compilation succeeds without errors
|
||||
/// - Test configuration file creation/update
|
||||
/// - Verify client name appears correctly via MCP protocol handshake
|
||||
/// - Test server start/stop functionality
|
||||
/// - Verify correct server key format (with/without port)
|
||||
/// - Test path format (absolute/relative) based on editor type
|
||||
/// - Verify factory automatically creates service for new editor type
|
||||
/// </summary>
|
||||
public static class UnityMcpPathResolver
|
||||
{
|
||||
private const string CURSOR_CONFIG_DIR = ".cursor";
|
||||
private const string VSCODE_CONFIG_DIR = ".vscode";
|
||||
private const string GEMINI_CONFIG_DIR = ".gemini";
|
||||
private const string CODEX_CONFIG_DIR = ".codex";
|
||||
private const string CODEIUM_CONFIG_DIR = ".codeium";
|
||||
private const string WINDSURF_SUBDIR = "windsurf";
|
||||
private const string MCP_CONFIG_FILE = "mcp.json";
|
||||
private const string CLAUDE_CODE_CONFIG_FILE = ".mcp.json";
|
||||
private const string GEMINI_CONFIG_FILE = "settings.json";
|
||||
private const string WINDSURF_CONFIG_FILE = "mcp_config.json";
|
||||
private const string CODEX_CONFIG_FILE = "config.toml";
|
||||
private const string MCP_INSPECTOR_CONFIG_FILE = ".inspector.mcp.json";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the project root directory.
|
||||
/// </summary>
|
||||
public static string GetProjectRoot()
|
||||
{
|
||||
return Path.GetDirectoryName(Application.dataPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base root for configuration files.
|
||||
/// When addRepositoryRoot is true and Git root differs, Git root is returned.
|
||||
/// </summary>
|
||||
private static string GetConfigurationRoot()
|
||||
{
|
||||
string projectRoot = GetProjectRoot();
|
||||
bool useRepositoryRoot = McpEditorSettings.GetAddRepositoryRoot();
|
||||
|
||||
if (!useRepositoryRoot)
|
||||
{
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
string gitRoot = GetGitRepositoryRoot();
|
||||
if (!string.IsNullOrEmpty(gitRoot))
|
||||
{
|
||||
return gitRoot;
|
||||
}
|
||||
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
private static string CombineWithConfigurationRoot(params string[] paths)
|
||||
{
|
||||
string root = GetConfigurationRoot();
|
||||
return Path.Combine(new[] { root }.Concat(paths).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes an absolute path relative to the current configuration root.
|
||||
/// If the absolutePath is under the configuration root, returns relative path with '/' separators.
|
||||
/// Otherwise returns the original absolutePath.
|
||||
/// </summary>
|
||||
public static string MakeRelativeToConfigurationRoot(string absolutePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(absolutePath))
|
||||
{
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
string root = GetConfigurationRoot();
|
||||
if (string.IsNullOrEmpty(root))
|
||||
{
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
// Ensure both have consistent trailing separators for comparison
|
||||
string normalizedRoot = root.Replace('\\', '/');
|
||||
string normalizedPath = absolutePath.Replace('\\', '/');
|
||||
|
||||
if (!normalizedRoot.EndsWith("/"))
|
||||
{
|
||||
normalizedRoot += "/";
|
||||
}
|
||||
|
||||
// Use case-insensitive comparison to support Windows file systems
|
||||
if (normalizedPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string relative = normalizedPath.Substring(normalizedRoot.Length);
|
||||
// Ensure forward slashes
|
||||
return relative.Replace('\\', '/');
|
||||
}
|
||||
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
private static string _cachedGitRepositoryRoot;
|
||||
private static bool _cachedGitRootComputed;
|
||||
private static bool _cachedGitRootDiffers;
|
||||
private static bool _cachedGitRootDifferenceComputed;
|
||||
|
||||
/// <summary>
|
||||
/// Clears cached Git repository detection results. Call when toggling repository-root setting.
|
||||
/// </summary>
|
||||
public static void InvalidateGitRootCache()
|
||||
{
|
||||
_cachedGitRepositoryRoot = null;
|
||||
_cachedGitRootComputed = false;
|
||||
_cachedGitRootDiffers = false;
|
||||
_cachedGitRootDifferenceComputed = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to get the Git repository root directory.
|
||||
/// Returns null when not found.
|
||||
/// </summary>
|
||||
public static string GetGitRepositoryRoot()
|
||||
{
|
||||
if (_cachedGitRootComputed)
|
||||
{
|
||||
return _cachedGitRepositoryRoot;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string projectRoot = GetProjectRoot();
|
||||
string currentDirectory = projectRoot;
|
||||
const int maxDepth = 10;
|
||||
int depth = 0;
|
||||
|
||||
while (!string.IsNullOrEmpty(currentDirectory) && depth < maxDepth)
|
||||
{
|
||||
string gitDirectoryPath = Path.Combine(currentDirectory, ".git");
|
||||
if (Directory.Exists(gitDirectoryPath) || File.Exists(gitDirectoryPath))
|
||||
{
|
||||
_cachedGitRepositoryRoot = currentDirectory;
|
||||
_cachedGitRootComputed = true;
|
||||
return _cachedGitRepositoryRoot;
|
||||
}
|
||||
|
||||
string parentDirectory = Path.GetDirectoryName(currentDirectory);
|
||||
if (string.IsNullOrEmpty(parentDirectory) || string.Equals(parentDirectory, currentDirectory, StringComparison.Ordinal))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning($"Failed to detect Git repository root: {ex.Message}");
|
||||
}
|
||||
|
||||
_cachedGitRepositoryRoot = null;
|
||||
_cachedGitRootComputed = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if Git repository root differs from Unity project root.
|
||||
/// </summary>
|
||||
public static bool GitRootDiffersFromProjectRoot()
|
||||
{
|
||||
if (_cachedGitRootDifferenceComputed)
|
||||
{
|
||||
return _cachedGitRootDiffers;
|
||||
}
|
||||
|
||||
string projectRoot = GetProjectRoot();
|
||||
string gitRoot = GetGitRepositoryRoot();
|
||||
|
||||
_cachedGitRootDiffers = !string.IsNullOrEmpty(gitRoot) && !string.Equals(gitRoot, projectRoot, StringComparison.Ordinal);
|
||||
_cachedGitRootDifferenceComputed = true;
|
||||
return _cachedGitRootDiffers;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to .cursor/mcp.json in the project root.
|
||||
/// </summary>
|
||||
public static string GetMcpConfigPath()
|
||||
{
|
||||
return CombineWithConfigurationRoot(CURSOR_CONFIG_DIR, MCP_CONFIG_FILE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the Claude Code configuration file (.mcp.json).
|
||||
/// </summary>
|
||||
public static string GetClaudeCodeConfigPath()
|
||||
{
|
||||
return CombineWithConfigurationRoot(CLAUDE_CODE_CONFIG_FILE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the VSCode configuration file (.vscode/mcp.json).
|
||||
/// </summary>
|
||||
public static string GetVSCodeConfigPath()
|
||||
{
|
||||
return CombineWithConfigurationRoot(VSCODE_CONFIG_DIR, MCP_CONFIG_FILE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the Gemini CLI configuration file (.gemini/settings.json).
|
||||
/// </summary>
|
||||
public static string GetGeminiCLIConfigPath()
|
||||
{
|
||||
return CombineWithConfigurationRoot(GEMINI_CONFIG_DIR, GEMINI_CONFIG_FILE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the Windsurf configuration file (~/.codeium/windsurf/mcp_config.json).
|
||||
/// </summary>
|
||||
public static string GetWindsurfConfigPath()
|
||||
{
|
||||
string homeDirectory = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
|
||||
return Path.Combine(homeDirectory, CODEIUM_CONFIG_DIR, WINDSURF_SUBDIR, WINDSURF_CONFIG_FILE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the MCP Inspector configuration file (.inspector.mcp.json).
|
||||
/// </summary>
|
||||
public static string GetMcpInspectorConfigPath()
|
||||
{
|
||||
return CombineWithConfigurationRoot(MCP_INSPECTOR_CONFIG_FILE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the Codex configuration file.
|
||||
/// Resolves to {projectRoot}/.codex/config.toml (or {gitRoot}/.codex/config.toml when repository root is enabled).
|
||||
/// </summary>
|
||||
public static string GetCodexConfigPath()
|
||||
{
|
||||
return CombineWithConfigurationRoot(CODEX_CONFIG_DIR, CODEX_CONFIG_FILE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration file path for the specified editor.
|
||||
/// </summary>
|
||||
/// <param name="editorType">The type of editor.</param>
|
||||
/// <returns>The path to the configuration file.</returns>
|
||||
public static string GetConfigPath(McpEditorType editorType)
|
||||
{
|
||||
return EditorConfigProvider.GetConfigPath(editorType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration file path for the specified editor using an explicit base root.
|
||||
/// For editors whose configs live under the project/repository root, the path is resolved under baseRoot.
|
||||
/// For editors with home-directory configs (e.g., Windsurf), returns their standard home path.
|
||||
/// </summary>
|
||||
/// <param name="editorType">Editor type</param>
|
||||
/// <param name="baseRoot">Base root directory (e.g., Git root or Unity project root)</param>
|
||||
/// <returns>Absolute path to the configuration file</returns>
|
||||
public static string GetConfigPathForRoot(McpEditorType editorType, string baseRoot)
|
||||
{
|
||||
string root = string.IsNullOrEmpty(baseRoot) ? GetProjectRoot() : baseRoot;
|
||||
|
||||
return editorType switch
|
||||
{
|
||||
McpEditorType.Cursor => Path.Combine(root, CURSOR_CONFIG_DIR, MCP_CONFIG_FILE),
|
||||
McpEditorType.ClaudeCode => Path.Combine(root, CLAUDE_CODE_CONFIG_FILE),
|
||||
McpEditorType.VSCode => Path.Combine(root, VSCODE_CONFIG_DIR, MCP_CONFIG_FILE),
|
||||
McpEditorType.GeminiCLI => Path.Combine(root, GEMINI_CONFIG_DIR, GEMINI_CONFIG_FILE),
|
||||
McpEditorType.McpInspector => Path.Combine(root, MCP_INSPECTOR_CONFIG_FILE),
|
||||
McpEditorType.Windsurf => GetWindsurfConfigPath(),
|
||||
McpEditorType.Codex => Path.Combine(root, CODEX_CONFIG_DIR, CODEX_CONFIG_FILE),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(editorType), editorType, null),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the .cursor directory.
|
||||
/// </summary>
|
||||
public static string GetCursorConfigDirectory()
|
||||
{
|
||||
return CombineWithConfigurationRoot(CURSOR_CONFIG_DIR);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the .vscode directory.
|
||||
/// </summary>
|
||||
public static string GetVSCodeConfigDirectory()
|
||||
{
|
||||
return CombineWithConfigurationRoot(VSCODE_CONFIG_DIR);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the .gemini directory.
|
||||
/// </summary>
|
||||
public static string GetGeminiConfigDirectory()
|
||||
{
|
||||
return CombineWithConfigurationRoot(GEMINI_CONFIG_DIR);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the .codeium/windsurf directory for Windsurf.
|
||||
/// </summary>
|
||||
public static string GetWindsurfConfigDirectory()
|
||||
{
|
||||
string homeDirectory = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
|
||||
return Path.Combine(homeDirectory, CODEIUM_CONFIG_DIR, WINDSURF_SUBDIR);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Codex configuration directory path.
|
||||
/// Resolves to {projectRoot}/.codex (or {gitRoot}/.codex when repository root is enabled).
|
||||
/// </summary>
|
||||
public static string GetCodexConfigDirectory()
|
||||
{
|
||||
return CombineWithConfigurationRoot(CODEX_CONFIG_DIR);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration directory for the specified editor (only if it exists).
|
||||
/// </summary>
|
||||
/// <param name="editorType">The type of editor.</param>
|
||||
/// <returns>The path to the configuration directory (null for Claude Code and MCP Inspector).</returns>
|
||||
public static string GetConfigDirectory(McpEditorType editorType)
|
||||
{
|
||||
return EditorConfigProvider.GetConfigDirectory(editorType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relative path from package base to TypeScript server file.
|
||||
/// </summary>
|
||||
/// <returns>The relative path to server.bundle.js</returns>
|
||||
private static string GetTypeScriptServerRelativePath()
|
||||
{
|
||||
return Path.Combine(McpConstants.TYPESCRIPT_SERVER_DIR, McpConstants.DIST_DIR, McpConstants.SERVER_BUNDLE_FILE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the full path to TypeScript server file from a base path.
|
||||
/// </summary>
|
||||
/// <param name="basePath">The base path to combine with</param>
|
||||
/// <returns>The full path to server.bundle.js</returns>
|
||||
private static string BuildTypeScriptServerPath(string basePath)
|
||||
{
|
||||
return Path.Combine(basePath, GetTypeScriptServerRelativePath());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the TypeScript server.
|
||||
/// Priority: 1. Local development, 2. Fixed path (Library/uLoopMCP), 3. Package path via Unity API, 4. PackageCache (fallback)
|
||||
/// </summary>
|
||||
public static string GetTypeScriptServerPath()
|
||||
{
|
||||
string projectRoot = GetProjectRoot();
|
||||
|
||||
// 1. Local development path (Packages/src) - highest priority for developers
|
||||
string localPath = Path.Combine(projectRoot, McpConstants.PACKAGES_DIR, McpConstants.SRC_DIR);
|
||||
string localServerPath = BuildTypeScriptServerPath(localPath);
|
||||
if (File.Exists(localServerPath))
|
||||
{
|
||||
return localServerPath;
|
||||
}
|
||||
|
||||
// 2. Fixed path in Library - stable path for mcp.json
|
||||
string fixedPath = Path.Combine(
|
||||
projectRoot,
|
||||
McpConstants.LIBRARY_DIR,
|
||||
McpConstants.ULOOPMCP_DIR,
|
||||
McpConstants.SERVER_BUNDLE_FILE
|
||||
);
|
||||
if (File.Exists(fixedPath))
|
||||
{
|
||||
return fixedPath;
|
||||
}
|
||||
|
||||
// 3. Package path via Unity Package Manager API (supports submodules and local packages)
|
||||
string packagePath = McpConstants.PackageResolvedPath;
|
||||
if (!string.IsNullOrEmpty(packagePath))
|
||||
{
|
||||
string serverPath = BuildTypeScriptServerPath(packagePath);
|
||||
if (File.Exists(serverPath))
|
||||
{
|
||||
return serverPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. PackageCache - fallback for initial setup before ServerBundleCopier runs
|
||||
string packageCacheDir = Path.Combine(projectRoot, McpConstants.LIBRARY_DIR, McpConstants.PACKAGE_CACHE_DIR);
|
||||
if (Directory.Exists(packageCacheDir))
|
||||
{
|
||||
string[] packageDirs = Directory.GetDirectories(packageCacheDir, McpConstants.PackageNamePattern);
|
||||
foreach (string packageDir in packageDirs)
|
||||
{
|
||||
string serverPath = BuildTypeScriptServerPath(packageDir);
|
||||
if (File.Exists(serverPath))
|
||||
{
|
||||
return serverPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base path of the package.
|
||||
/// Supports both installation via Package Manager and local development.
|
||||
/// </summary>
|
||||
public static string GetPackageBasePath()
|
||||
{
|
||||
string projectRoot = GetProjectRoot();
|
||||
|
||||
// 1. First, check the path for local development (Packages/src).
|
||||
string localPath = Path.Combine(projectRoot, McpConstants.PACKAGES_DIR, McpConstants.SRC_DIR);
|
||||
string localServerPath = BuildTypeScriptServerPath(localPath);
|
||||
if (File.Exists(localServerPath))
|
||||
{
|
||||
// Using local package path
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// 2. Search for the path when installed via Package Manager.
|
||||
string packageCacheDir = Path.Combine(projectRoot, McpConstants.LIBRARY_DIR, McpConstants.PACKAGE_CACHE_DIR);
|
||||
if (Directory.Exists(packageCacheDir))
|
||||
{
|
||||
string[] packageDirs = Directory.GetDirectories(packageCacheDir, McpConstants.PackageNamePattern);
|
||||
|
||||
foreach (string packageDir in packageDirs)
|
||||
{
|
||||
string serverPath = BuildTypeScriptServerPath(packageDir);
|
||||
if (File.Exists(serverPath))
|
||||
{
|
||||
// Using Package Manager package path
|
||||
return packageDir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If neither is found, return the local path (with an error log).
|
||||
|
||||
return localPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects if running in WSL2 environment.
|
||||
/// </summary>
|
||||
/// <returns>True if running in WSL2, false otherwise.</returns>
|
||||
public static bool IsWSL2Environment()
|
||||
{
|
||||
// Check WSL_DISTRO_NAME environment variable (WSL2 specific)
|
||||
string wslDistroName = System.Environment.GetEnvironmentVariable("WSL_DISTRO_NAME");
|
||||
if (!string.IsNullOrEmpty(wslDistroName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check WSLENV environment variable (WSL1/WSL2 common)
|
||||
string wslEnv = System.Environment.GetEnvironmentVariable("WSLENV");
|
||||
if (!string.IsNullOrEmpty(wslEnv))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if /proc/version contains Microsoft (WSL indicator)
|
||||
string versionFilePath = "/proc/version";
|
||||
if (File.Exists(versionFilePath))
|
||||
{
|
||||
string versionContent = File.ReadAllText(versionFilePath);
|
||||
if (versionContent.Contains("Microsoft", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts Windows path to WSL2 path format.
|
||||
/// Example: C:\Users\user\project -> /mnt/c/Users/user/project
|
||||
/// </summary>
|
||||
/// <param name="windowsPath">Windows absolute path</param>
|
||||
/// <returns>WSL2 formatted path</returns>
|
||||
public static string ConvertWindowsPathToWSL2(string windowsPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(windowsPath))
|
||||
{
|
||||
return windowsPath;
|
||||
}
|
||||
|
||||
// Check if it's already a Unix-style path
|
||||
if (windowsPath.StartsWith("/"))
|
||||
{
|
||||
return windowsPath;
|
||||
}
|
||||
|
||||
// Check if it's a Windows absolute path (starts with drive letter)
|
||||
if (windowsPath.Length >= 3 && windowsPath[1] == ':' && windowsPath[2] == '\\')
|
||||
{
|
||||
char driveLetter = char.ToLower(windowsPath[0]);
|
||||
string pathWithoutDrive = windowsPath.Substring(3); // Remove "C:\"
|
||||
string unixPath = pathWithoutDrive.Replace('\\', '/');
|
||||
return $"/mnt/{driveLetter}/{unixPath}";
|
||||
}
|
||||
|
||||
// If it's not a Windows absolute path, return as-is
|
||||
return windowsPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TypeScript server path with WSL2 conversion if needed for Claude Code.
|
||||
/// </summary>
|
||||
/// <param name="forClaudeCode">Whether this path is for Claude Code configuration</param>
|
||||
/// <returns>TypeScript server path, converted to WSL2 format if needed</returns>
|
||||
public static string GetTypeScriptServerPath(bool forClaudeCode = false)
|
||||
{
|
||||
string serverPath = GetTypeScriptServerPath();
|
||||
|
||||
if (forClaudeCode && IsWSL2Environment())
|
||||
{
|
||||
return ConvertWindowsPathToWSL2(serverPath);
|
||||
}
|
||||
|
||||
return serverPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee05dd8fa5e8d49bab59f169f0afddeb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user