chore(unity): 固定Unity CLI Loop本地包并移除UnitySkills依赖

This commit is contained in:
Liubing\LB
2026-09-28 18:14:59 +08:00
parent 0eb43b2b40
commit c4ae02def9
952 changed files with 70137 additions and 11 deletions

View File

@@ -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);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 624dd200a07ed4014a6b3ee4dcdc422e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4a70e78768ae245d59d047c171c90b01
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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";
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4fcde60743f8a4f4eacd487b0e4cd05f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4dc7a8ec399614c7685b8823da872e77
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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),
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3d295b0b4f7104e35b652a7bf9bf5455
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e42e2ea5eb5a74ef381c42a8a9f220e3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ebc00e6a5bc9345e994b8d62e67c5b91
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 22c6c65b1ee7c4e3198401a1c9e4c06b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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()
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e3a3b3c96615941bdada5ad85cb78555
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 180d305a8cf744775931f29008a2d438
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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}");
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db4bbeeb39d1a4b51b2cf56ccccb7842
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 76229b809bc6c4d63a85cab5965ac4ca
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 868eb3bbbea7f4d5485258dd9c64c052
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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());
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8134fd2d31bde432e9b69459511ee909
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a89ab75dad5644d9d897cf88a1984689
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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"
);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e0fa39b2f287d4a02b4312554abb5961
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3e70bd0dd6ae54f968571002e64f2509
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 93ef9e15f9e684807b7e8878b0cc6e3b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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";
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 20c01e8033581448c877121b760d2fed
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
namespace io.github.hatayama.uLoopMCP
{
public static class McpVersion
{
public const string VERSION = "2.2.0"; // x-release-please-version
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db3c2fe880474407a80706968e49f262
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1c100d0e898aa4a23b017cf167b167f0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: eae955b7b19094c3c812d3b1c6e469cc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 65a856359e56649e29149a948ea43225
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3c1373f1071344961b485f49aaa31f1a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 28376f0a2aebe4909bb91e8b31da908c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5fa939f7e20a944e6a1a05461187ce13
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1b02e268ca8d54e7cb1d58fb851328e5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 13f53756ed78843e680c0a9cf28305ef
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ee05dd8fa5e8d49bab59f169f0afddeb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: